Cliffy 1.0: typed CLIs across Deno, Node.js and Bun
Cliffy 1.0 marks its documented public APIs as stable, with declaration-derived TypeScript types, generated help and completions, and support for Deno, Node.js and Bun.

deno add jsr:@cliffy/command@1.0.0
Command-line tools are having a good decade. Package managers, build tools, deployment clients, database shells, coding agents: for much of the software developers touch every day, the terminal is the primary interface. People expect a CLI to explain itself, report errors without dumping a stack trace, complete its own flags in their shell, and ask for what it needs instead of failing.
JavaScript has had good tools for this for a long time. Commander, yargs, oclif
and Inquirer have covered this ground for years, and they are good. Cliffy makes
different tradeoffs. TypeScript has become the default for much of JavaScript
tooling, and a command declaration should only have to state a fact once. If
--port is declared as a number, nothing else should have to be told.
The assembly problem is the quieter one, and it grows with the tool. A CLI starts as an argument parser and ends up wanting prompts, tables, colors, raw key handling and more. With a parser-focused library, those capabilities usually come from separate dependencies with separate release cycles. Cliffy keeps a broader family of CLI building blocks in one project without forcing you to install all of them.
Cliffy has been chipping away at this since its first release in March 2020. Today it reaches 1.0.
Declare the command, get the types
Cliffy is a toolkit for building command-line tools on Deno, Node.js and Bun. The premise is that the declaration of a command is the single source of truth: the parser, the type of the parsed result, the generated help and the shell completions all fall out of the same description.
import { Command } from "jsr:@cliffy/command@1.0.0";
await new Command()
.name("reverse-proxy")
.description("Configure a reverse proxy.")
.version("1.0.0")
.option("-p, --port <port:number>", "The port number for the local server.", {
default: 8080,
})
.option("--host <hostname>", "The host name for the local server.", {
default: "localhost",
})
.argument("[domain]", "The upstream domain.", { default: "deno.com" })
.action(({ port, host }, domain) => {
console.log(`Proxy ${host}:${port} to ${domain}`);
})
.parse();
Nothing is registered twice. --help is rendered from that, --version works
and <port:number> is validated before the action runs. Cliffy reads the
declaration at runtime, while TypeScript reads the same string literal to infer
the action's parameters. The options object is { port: number; host: string },
so renaming a flag or changing its type surfaces at the call site instead of in
production.
The same holds for arguments, variadic arguments, and dotted and collected options. When a value can be absent, the type says so. When an option has a default, it is not optional.
iNOTE
On Deno that file is the whole program.
deno run main.tsruns it as it is, with nopackage.json, notsconfig.json, nodeno.jsonand no build step between writing it and using it, anddeno compileturns it into a single binary for people who have no runtime installed.
Help and completions come from the same place. The generated help is rendered from the command tree rather than from a template you fill in, and the Bash, fish and Zsh completion scripts come from the same place, including subcommands, option names and the values of enum types. There is no separate help inventory to keep in sync.
Where Cliffy is the better fit
Cliffy is not the best choice for every CLI. It is a better fit when you want three things together: a command API that derives option and positional argument types from its declarations, official support for Deno, Node.js and Bun and a family of first-party building blocks for CLIs. For a Node.js tool that only needs argument parsing, Commander and yargs are focused choices, and oclif suits CLIs built around a generated project, a plugin system and first-party tooling for standalone archives and OS installers.
Global options and subcommands
Type safety is not unique to Cliffy, but deriving an option's property name,
requiredness and registered value type from one definition string is where
Cliffy got there first. In --port <port:number>, port becomes the property
name, the angle brackets make the value required and number is resolved
through the same map that holds any custom type registered with .type().
Cliffy has done this since v0.23.0 in April 2022.
Each example below defines a global --verbose option and two sibling
subcommands. serve <directory> has a local --port option, while
list <directory> only uses the inherited global option. In every handler,
directory is a string and verbose remains a typed boolean flag. In the
serve handler, port is a number with a default of 8000. None needs a
separately declared result interface. The examples use Cliffy 1.0.0, Commander
14, yargs 18 and oclif 4, versions available when this release was published.
npx jsr add @cliffy/command@1.0.0import { Command } from "@cliffy/command";
await new Command()
.name("files")
.globalOption("--verbose", "Show verbose output.")
.command("serve <directory>", "Serve a directory.")
.option("--port <port:number>", "Port to listen on.", { default: 8000 })
.action(({ verbose, port }, directory) => {
console.log(`Serving ${directory} on port ${port.toFixed(0)}`);
if (verbose) console.log("Verbose logging enabled.");
})
.command("list <directory>", "List a directory.")
.action(({ verbose }, directory) => {
console.log(`Listing ${directory}`);
if (verbose) console.log("Verbose logging enabled.");
})
.parse();
All four accept the same command forms once their files executable is wired
up:
$ files serve ./public --port 9001 --verbose
$ files list ./public --verbose
The second subcommand makes the setup difference visible. Cliffy adds a sibling by continuing the same command chain. Commander adds another declaration from the root command, yargs adds another builder and handler pair and oclif adds another command class, conventionally in another file, which Cliffy supports as well.
All four remove the usual source of drift, a separately maintained result
interface, but they get the types to the handler differently. Cliffy merges
inherited global options into the child action's options object. Commander
infers the subcommand's own arguments and options and hands back the typed
combination from optsWithGlobals(). In yargs the parent type travels through
the callback builder, and in oclif each command class combines its own args
and flags with the shared baseFlags.
Environment variables are inputs too
All four can read configuration from environment variables. Cliffy lets a variable stand on its own, with a name, type and description. It is validated like any other value and gets its own section in generated help:
await new Command()
.env("PORT=<port:number>", "Port to listen on.")
.action(({ port }) => console.log(`Listening on ${port}`))
.parse();
It can still fill an option, it just does not need one to exist.
| Library | Environment-variable model |
|---|---|
| Cliffy | Independent typed input, or an option source |
| yargs | Prefix convention |
| Commander | Attribute on an option |
| oclif | Attribute on a flag |
Setup requirements
Their setup requirements differ too:
- Cliffy needs a single package for inference, numeric conversion,
validation, global-option propagation and the generated help and completion
metadata. Installing from JSR is where it asks for more on Node.js:
npx jsr addwrites an@jsrregistry entry into.npmrc, while pnpm resolvesjsr:specifiers on its own. - Commander adds
extra-typingsplus a parser callback for the numeric value. - yargs is written in TypeScript but ships no declarations, so its types
come from DefinitelyTyped,
@types/yargs17.x against a yargs 18.x runtime. - oclif needs an
oclifblock inpackage.json, or an rc file, to tell it how to find commands, which its generator writes for you.
One release family, install only what you need
Today, the toolkit consists of eight packages, designed together and released together:
- command for commands, flags, help and completions
- prompt for interactive input
- table for terminal tables with borders, padding and nesting
- ansi for chainable escape sequences
- flags for argument parsing on its own
- keycode and keypress for raw terminal input
- testing for snapshot testing CLI output
The packages remain separate public APIs, and dependencies flow from
higher-level packages to lower-level packages. @cliffy/table and
@cliffy/flags do not depend on @cliffy/command, so either works on its own,
while @cliffy/command renders its help with the table package and parses with
the flags package. Install several and they share a release family and the same
internal runtime abstractions and terminal utilities.
@cliffy/prompt on its own:
import { Input, Number, Secret } from "@cliffy/prompt";
const hostname = await Input.prompt({ message: "Enter the hostname" });
const port = await Number.prompt({ message: "Enter the port number" });
const password = await Secret.prompt({ message: "Enter your password" });
Every package except @cliffy/testing, which runs its snapshot tests under
Deno, supports Deno, Node.js and Bun from the same source. The runtime-specific
parts are isolated behind a small internal layer, and each package is published
once on JSR.
Running on three runtimes was not the original plan.
How it got here
Cliffy goes back to the early days of Deno, well before Deno itself reached 1.0 in May 2020. It started in early 2019 as a flag parser, written for fun to try out a runtime that ran TypeScript without a build step. Deno was on 0.2.x then, and its community hung out on Gitter.
Work was occasional at first, and by the time the repository's first commit
landed in March 2020 it had already grown a command module. deno.land/x/cliffy
appeared two weeks later alongside v0.1.0, back when publishing a module meant
opening a pull request against a database.json file in the registry's website
repository.
It was Deno-only, and that was the point. Deno runs TypeScript without a build step and ships a standard library you can import directly, which is exactly the environment where a CLI toolkit can be a set of small modules rather than a framework with a scaffolding command. That is still true, and Deno is still the reference implementation.
What changed the calculation was watching what people did with it. Library consumers choose a runtime as part of integrating a dependency. CLI users usually care more about whether a command works in their environment than which JavaScript runtime is underneath it. Tools built with Cliffy kept running into that boundary, so in 2024 the packages moved to JSR and learned to run on Node.js and Bun as well.
The road here was not short. The first release candidate was tagged in July 2023 and the eighth in July 2025. Alongside the release work, cliffy.io was rebuilt as an Angular SSR application hosted on Deno Deploy. Life outside the project also took priority for stretches of that time, so from the outside Cliffy sometimes looked more abandoned than it was.
What 1.0 means
Cliffy has been usable for years, but remaining on a release candidate had practical costs. Installing from JSR required an explicit prerelease version, and issue #771 tracked the request for an install command without one. Users also kept asking what still stood between the latest release candidate and a stable release. 1.0 removes that friction and moves Cliffy out of SemVer's initial-development phase.
1.0 is a commitment rather than a feature. Cliffy has always used
Semantic Versioning 2.0. In the 0.x line,
breaking changes moved the minor version, while features and fixes moved the
patch version. With 1.0, intentional breaking changes to documented public APIs
move the major version instead, and anything intended for removal gets
deprecated first in a release that also says what replaces it. The deprecated
types accumulated across command, flags, prompt and table in the 0.x
line are gone, so 1.0 starts without carrying five years of compatibility shims
into that promise.
None of which claims the work is finished. There are rough edges and there is a list, and a version number should not imply otherwise. What 1.0 says is that work on that list will continue within a defined compatibility contract.
In this release
Here is what went into 1.0 itself for anyone coming from a release candidate. If you are coming from 0.x, the release candidate notes cover the rest, including Node.js and Bun support, the move to JSR and the testing module.
Argument descriptions
.arguments() takes the whole signature as a single string, which left nowhere
to say what each argument means. It now takes a second argument, an array of
descriptions matched to the arguments by position:
import { Command } from "@cliffy/command";
await new Command()
.name("greet")
.arguments("<name> [times:number]", [
"Who to greet.",
"How many times to repeat the greeting.",
])
.action(() => {})
.parse();
The .argument() method
.argument() declares arguments one at a time instead, which keeps each
description next to the argument it belongs to and takes per-argument options:
import { Command } from "@cliffy/command";
await new Command()
.name("greet")
.argument("<name>", "Who to greet.")
.argument("[times:number]", "How many times to repeat the greeting.", {
default: 1,
})
.action((_options, name, times) => {
for (let i = 0; i < times; i++) {
console.log(`Hello, ${name}!`);
}
})
.parse();
Its options object takes default for a fallback when the argument is omitted,
as above, and value, which maps the parsed argument the same way option
processing does.
generateShellCompletions()
The completions command is convenient, but it means shipping a subcommand you
might not want in your CLI. You can now generate the same scripts directly:
import { Command } from "@cliffy/command";
import { generateShellCompletions } from "@cliffy/command/completions";
const cli = new Command().name("files");
console.log(generateShellCompletions(cli, "zsh"));
It takes bash, fish or zsh. Its optional third argument is an options
object with a name property if the script should use something other than the
command's own name.
The secret type
A secret option renders its default as ****** in the generated help, so a
default resolved at runtime never reaches the terminal:
import { Command } from "@cliffy/command";
await new Command()
.option("-t, --token <token:secret>", "API token.", {
default: () => readCredentials().token,
})
.parse();
Without it, anyone running --help on a configured machine prints their own
token, and it travels wherever that output gets pasted.
defaultText
defaultText overrides how a default is displayed. This is useful when the real
value is noise: a resolved path, a generated ID or a default that only makes
sense at runtime.
import { Command } from "@cliffy/command";
await new Command()
.option("-o, --out <path:file>", "Where to write the output.", {
default: () => resolveOutputDir(),
defaultText: "the current directory",
})
.parse();
It also wins over a type's own display text, so it doubles as the escape hatch
when a type like secret masks something you would rather describe.
Vim keybindings
Select, checkbox and the other list prompts now accept h, j, k and l for
left, down, up and right, alongside the arrow keys.
parseFlags() without arguments
parseFlags() can be called with no arguments. It then reads the runtime's own
argv: Deno.args on Deno, process.argv on Node.js and Bun.argv on Bun.
import { parseFlags } from "@cliffy/flags";
const { flags } = parseFlags();
Sorted upgrade versions
The upgrade provider sorts the versions it lists, so upgrade --list-versions
prints them in order instead of in whatever order the registry returned.
Breaking changes
The third argument of the .command() method moved into an options object:
cli.command("foo", "...", true); // before
cli.command("foo", "...", { override: true }); // after
The types deprecated across command, flags, prompt and table during the
0.x line have been removed.
Version checks are now skipped when printing help if they would trigger a
permission prompt, so a CLI that used to ask for network access on --help
stays quiet. If you implement a custom upgrade provider, it has to implement
hasRequiredPermissions().
Type.infer is deprecated in favor of InferType. It still works, but it is
planned for removal in the next major.
Quality of life improvements
- Calling
.parse()on a child command no longer throws, and a default command only runs when the parent command defines no positional arguments. - Options that depend on environment variables work again, and the types for collected dotted and wildcard options are correct.
- Prompts no longer panic on a keypress when there are no navigation options.
The tagged release has
the rest, including the @cliffy/ansi method bindings and the @cliffy/testing
Node fixes.
Get started
The stable release no longer needs an explicit version in the install command, and on Deno that is the only setup there is. Three commands end to end, with nothing to configure in between:
deno add jsr:@cliffy/command # add it
deno run main.ts # run it
deno compile main.ts # ship it
Your users get the same shortcut. deno run jsr:@you/cli runs a published CLI
without installing anything, and deno install --global jsr:@you/cli puts it on
their PATH. Publishing is optional: Deno runs a module from any URL, so a
single file on GitHub or behind a CDN is already a CLI someone else can run.
The 1.0 documentation includes install instructions for Deno, pnpm, Yarn, npm and Bun.
Acknowledgements
Cliffy grew from a personal tool into something other people build on, through bug reports, questions and code. For the code that landed before the 1.0 tag, thank you:
Alec Brunelle, Andrew Thauer, Anner Visser, Asher Gomez, Avery Pierce, Can Rau, Chris Markiewicz, DrakeTDL, Felix Zieger, Filippo Rossi, Fuke Kazuki, Jarek Toro, Jerry Wang, Jesse Jackson, Joe Harrison, JOTSR, Kasper Møller Andersen, Kid, KnorpelSenf, lionel-rowe, littletof, mackie, Marcus Hultman, Markus Brandt, mch, Mufasa🦁, Nakajima Takuya, Nicolas Kleiderer, Ony, Percy, Quentin Michel, Rafael Fraga Walter, Robin Morton, Romuald Quantin, Ruben Fiszel, Ryan Beesley, Saulo Vallory, scarf, Shu Kutsuzawa, Steffen Trog, Tristan F, Tugrul Ates, ud2, wlfio, Yacine Hmito and カワリミ人形.
Contributions are welcome. The contributing guide covers how to get started.