Blog

Cliffy 1.0: typed CLIs across Deno, Node.js and Bun

c4spar12 min read

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.

One Cliffy command branching into matching output across three runtimes.
    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.

i

NOTE

On Deno that file is the whole program. deno run main.ts runs it as it is, with no package.json, no tsconfig.json, no deno.json and no build step between writing it and using it, and deno compile turns 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.

~/filesnode 22
npx jsr add @cliffy/command@1.0.0
import { 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.

LibraryEnvironment-variable model
CliffyIndependent typed input, or an option source
yargsPrefix convention
CommanderAttribute on an option
oclifAttribute 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 add writes an @jsr registry entry into .npmrc, while pnpm resolves jsr: specifiers on its own.
  • Commander adds extra-typings plus a parser callback for the numeric value.
  • yargs is written in TypeScript but ships no declarations, so its types come from DefinitelyTyped, @types/yargs 17.x against a yargs 18.x runtime.
  • oclif needs an oclif block in package.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.