Blog

Cliffy 1.1.0: responsive help, positional arguments, and error context

c4spar4 min read

We're happy to announce the release of Cliffy 1.1.0. Generated help now adapts to the terminal width, the flags parser understands positional arguments, and error handlers receive the options and arguments that were parsed before the failure.

~/deploydeno
deploy --help Usage: deploy <target> Version: 1.1.0 Options: -h, --help - Show this help. -V, --version - Show the version number for this program. --dry-run - Print what would happen without doing it. --timeout <ms> - Give up after this many milliseconds.
    deno add jsr:@cliffy/command@1.1.0
  

Command

Responsive help output

Generated help used to assume a wide terminal. Long descriptions ran off the edge in a narrow one, and there was no way to tell Cliffy how much room it had. Help is now laid out from the terminal width, and descriptions wrap into their column instead of overflowing.

Two help options give you direct control. width sets the width to render at instead of the detected terminal width, and maxWidth caps it so help stays readable on a very wide terminal:

import { Command } from "@cliffy/command";

await new Command()
  .name("deploy")
  .help({ maxWidth: 100 })
  .parse();

The layout itself comes from the table module, which gained flexible column widths in the same release for this purpose.

ErrorContext

Error handlers used to receive the error and the failed command, so recovering what the user actually typed meant parsing it again yourself. Handlers now get a third argument, an ErrorContext carrying the options and arguments that were parsed before the failure:

import { Command } from "@cliffy/command";

await new Command()
  .globalOption("-v, --verbose", "Print stack traces.")
  .error((error, _cmd, { options }) => {
    console.error(options.verbose ? error.stack : error.message);
  })
  .parse();

Empty option and argument values

An empty string is now skipped for optional options and positional arguments, as if nothing had been passed, instead of being taken as the value. A declared default then applies. Without one the option is left out of the options object. The behaviour comes from the parser, see empty values.

import { Command } from "@cliffy/command";

await new Command()
  .name("greet")
  .option("--name [name:string]", "Who to greet.", { default: "world" })
  .argument("[times:number]", "How many times to repeat the greeting.", {
    default: 1,
  })
  .action(({ name }, times) => {
    for (let i = 0; i < times; i++) console.log(`Hello, ${name}!`);
  })
  .parse();
$ greet --name "" ""
Hello, world!

It makes no difference whether the value is written [name:string] or <name:string>. What decides it is whether the option or argument itself is required, not how its value is declared.

Positional arguments keep their place. An empty value becomes undefined rather than dropping out, so the arguments after it do not shift. Position carries no meaning inside a rest argument, so there the empty values are dropped from the list instead:

import { Command } from "@cliffy/command";

await new Command()
  .name("example")
  .arguments("[foo] [bar] [baz] [...beep]")
  .action((_options, ...args) => console.log(args))
  .parse();
$ example "" bar-value "" "" "" beep-value-3 "" beep-value-5
[ undefined, "bar-value", undefined, "beep-value-3", "beep-value-5" ]

foo and baz hold their positions as undefined, while the three empty values passed to beep are gone.

Help for container commands

A command that only groups subcommands and has no action of its own now prints its help when called with no arguments, instead of doing nothing. It saves calling this.showHelp() from an action handler that exists for nothing else.

It is on by default and auto in the help options turns it off:

import { Command } from "@cliffy/command";

await new Command()
  .name("tool")
  .help({ auto: false })
  .command("sub", "A subcommand.")
  .parse();

Table

Flexible column widths

@cliffy/table gains flexGrow, flexShrink and the flex shorthand, both per column and as table-level defaults. Columns can absorb or give up space, which is what lets the help description column take whatever is left over:

import { Table } from "@cliffy/table";

new Table()
  .body([["Name", "A description that gives up space when the table is tight"]])
  .maxWidth(60)
  .flexShrink([0, 1])
  .render();

The same three are available on Column, as methods and as options, so a single column can be configured on its own instead of through a table-level array:

import { Column, Table } from "@cliffy/table";

new Table()
  .body([["Name", "A description that gives up space when the table is tight"]])
  .maxWidth(60)
  .columns([Column.from({ flexShrink: 0 }), Column.from({ flexShrink: 1 })])
  .render();

Flex needs a finite maxWidth on the table to have anything to lay out against. Without one it is a no-op.

Flags

Positional arguments

@cliffy/flags parses positional arguments rather than leaving everything that isn't a flag in unknown. Declare them with args and each value is validated and converted with its declared type, the same way option values are:

import { parseFlags } from "@cliffy/flags";

const { flags, args } = parseFlags(["--count", "3", "file.txt"], {
  flags: [{ name: "count", type: "number" }],
  args: [{ name: "file", type: "string" }],
});

console.log(flags); // { count: 3 }
console.log(args); // [ "file.txt" ]

This is runtime parsing only. args comes back as Array<unknown> | undefined.

Parsed flags

The parse context now carries parsedFlags, the raw flag tokens in the order they were typed. That is how Cliffy knows whether a value came from the command line or from a default.

import { parseFlags } from "@cliffy/flags";

const ctx = parseFlags(["--count", "3", "file.txt"], {
  flags: [{ name: "count", type: "number" }],
  args: [{ name: "file", type: "string" }],
});

console.log(ctx.parsedFlags); // [ "--count", "3" ]

Positional arguments are left out, so what remains is exactly what the user typed as flags.

Empty values

An empty string passed to an optional option or argument is now skipped rather than taken as the value, so a declared default applies instead of being overwritten with "". Positional arguments become undefined so the arguments after them keep their places, and inside a rest argument they are dropped. The parsing lives here, and commands inherit it, see empty option and argument values.

Testing

Environment variables per test and step

Snapshot tests take an env option, and each step can set its own, so one test file can cover the same code under different environments:

import { snapshotTest } from "@cliffy/testing";

await snapshotTest({
  name: "should read the log level from the environment",
  meta: import.meta,
  steps: {
    "quiet": { env: { LOG_LEVEL: "error" } },
    "loud": { env: { LOG_LEVEL: "debug" } },
  },
  fn() {
    console.log(Deno.env.get("LOG_LEVEL"));
  },
});

Filtering steps with only

only works on individual steps, not just whole tests, so you can narrow a failing suite to one step while you work on it:

import { snapshotTest } from "@cliffy/testing";

await snapshotTest({
  name: "should render the prompt",
  meta: import.meta,
  steps: {
    "with a default": {},
    "with a hint": { only: true },
  },
  fn() {
    // ...
  },
});

Quality of life improvements

  • snapshotTest is faster.
  • Leading dashes are allowed in positional arguments.
  • parseFlags throws TooManyArgumentsError when it receives more positional arguments than were declared, rather than collecting the extras in ctx.unknown.
  • Special characters in option descriptions are escaped for zsh completions.
  • Error messages are suppressed while generating completions, so a broken command doesn't corrupt the generated script.

Fixed in 1.0.1

Shipped in the patch since 1.0:

  • Argument type inference for mapped arguments.
  • Default command handling moved into parseCommand.
  • --flag parsing after a variadic option.
  • Bracketed paste and escape sequence parsing in keycode.
  • Correct return types for action handlers and generateShellCompletions.
  • Error classes in flags get their prototypes assigned correctly, so instanceof works.

Acknowledgements

Responsive help came out of #761 by Chris Markiewicz, which laid the terminal-width layout and the table's flexShrink that the rest of this release builds on. Thank you.

Contributions are welcome. The contributing guide covers how to get started.