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

*Published on 2026-02-10 by c4spar*

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.

```ts
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.

> [!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.

<!--tabs-start-->

### Cliffy

```terminal ~/files node 22
$ npx jsr add @cliffy/command@1.0.0
```

```ts
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();
```

### Commander

```terminal ~/files node 22
$ npm install commander@14.0.3 @commander-js/extra-typings@14.0.0
```

```ts
import { Command } from "@commander-js/extra-typings";

const cli = new Command()
  .name("files")
  .option("--verbose", "Show verbose output.");

cli
  .command("serve <directory>")
  .description("Serve a directory.")
  .option(
    "--port <port>",
    "Port to listen on.",
    (value) => Number.parseInt(value, 10),
    8000,
  )
  .action((directory, { port }, command) => {
    const { verbose } = command.optsWithGlobals();
    console.log(`Serving ${directory} on port ${port.toFixed(0)}`);
    if (verbose) console.log("Verbose logging enabled.");
  });

cli
  .command("list <directory>")
  .description("List a directory.")
  .action((directory, _options, command) => {
    const { verbose } = command.optsWithGlobals();
    console.log(`Listing ${directory}`);
    if (verbose) console.log("Verbose logging enabled.");
  });

cli.parse();
```

### yargs

```terminal ~/files node 22
$ npm install yargs@18.0.0
$ npm install --save-dev @types/yargs@17.0.35
```

```ts
import yargs from "yargs";
import { hideBin } from "yargs/helpers";

await yargs(hideBin(process.argv))
  .scriptName("files")
  .option("verbose", {
    type: "boolean",
    description: "Show verbose output.",
    global: true,
  })
  .command(
    "serve <directory>",
    "Serve a directory.",
    (command) =>
      command.positional("directory", {
        type: "string",
        description: "Directory to serve.",
        demandOption: true,
      })
      .option("port", {
        type: "number",
        description: "Port to listen on.",
        default: 8000,
      }),
    ({ verbose, directory, port }) => {
      console.log(`Serving ${directory} on port ${port.toFixed(0)}`);
      if (verbose) console.log("Verbose logging enabled.");
    },
  )
  .command(
    "list <directory>",
    "List a directory.",
    (command) =>
      command.positional("directory", {
        type: "string",
        description: "Directory to list.",
        demandOption: true,
      }),
    ({ verbose, directory }) => {
      console.log(`Listing ${directory}`);
      if (verbose) console.log("Verbose logging enabled.");
    },
  )
  .demandCommand(1)
  .help()
  .parse();
```

### oclif

```terminal ~ node 22
$ npx oclif@4 generate files --bin files --module-type ESM --package-manager npm --yes
$ cd files
$ npx oclif@4 generate command serve
$ npx oclif@4 generate command list
```

`src/base-command.ts`:

```ts
import { Command, Flags } from "@oclif/core";

export abstract class BaseCommand extends Command {
  static baseFlags = {
    verbose: Flags.boolean({ description: "Show verbose output." }),
  };
}
```

`src/commands/serve.ts`:

```ts
import { Args, Flags } from "@oclif/core";

import { BaseCommand } from "../base-command.js";

export default class Serve extends BaseCommand {
  static args = {
    directory: Args.string({
      description: "Directory to serve.",
      required: true,
    }),
  };

  static description = "Serve a directory.";

  static flags = {
    port: Flags.integer({
      description: "Port to listen on.",
      default: 8000,
    }),
  };

  async run(): Promise<void> {
    const { args, flags } = await this.parse(Serve);
    this.log(`Serving ${args.directory} on port ${flags.port.toFixed(0)}`);
    if (flags.verbose) this.log("Verbose logging enabled.");
  }
}
```

`src/commands/list.ts`:

```ts
import { Args } from "@oclif/core";

import { BaseCommand } from "../base-command.js";

export default class List extends BaseCommand {
  static args = {
    directory: Args.string({
      description: "Directory to list.",
      required: true,
    }),
  };

  static description = "List a directory.";

  async run(): Promise<void> {
    const { args, flags } = await this.parse(List);
    this.log(`Listing ${args.directory}`);
    if (flags.verbose) this.log("Verbose logging enabled.");
  }
}
```

<!--tabs-end-->

All four accept the same command forms once their `files` executable is wired
up:

```console
$ 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:

```ts
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 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:

```ts
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](https://github.com/c4spar/cliffy/issues/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](https://semver.org/spec/v2.0.0.html). 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](https://github.com/c4spar/cliffy/releases) 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:

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```ts
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.

```ts
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:

```ts
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](https://github.com/c4spar/cliffy/releases/tag/v1.0.0) 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:

```sh
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](/docs/v1.0.0/command) 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](https://github.com/c4spar/cliffy/blob/main/CONTRIBUTING.md)
covers how to get started.
