Cliffy 1.1.0: responsive help, positional arguments, and error context
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.
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
snapshotTestis faster.- Leading dashes are allowed in positional arguments.
parseFlagsthrowsTooManyArgumentsErrorwhen it receives more positional arguments than were declared, rather than collecting the extras inctx.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. --flagparsing after a variadic option.- Bracketed paste and escape sequence parsing in
keycode. - Correct return types for action handlers and
generateShellCompletions. - Error classes in
flagsget their prototypes assigned correctly, soinstanceofworks.
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.