# Cliffy 1.3.0: lazy sub commands, @cliffy/upgrade, and env options

*Published on 2026-09-18 by c4spar*

## Command

### Lazy loading sub commands

A CLI that registers fifty sub commands pays for all fifty on every run, even
when the user asked for one. Pass a function returning a dynamic import instead
of a command instance, and the module is only evaluated when that sub command is
actually invoked:

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

export const cli = new Command()
  .name("example")
  .description("Example command with lazy loading.")
  .command("foo", () => import("./foo_command.ts"))
  .command("bar", () => import("./bar_command.ts"));

if (import.meta.main) {
  await cli.parse();
}
```

Running `example foo` loads `foo` and nothing else. `--help` is the exception:
it loads every sub command first, because their descriptions live in the modules
it would otherwise skip, so it costs marginally more than declaring them
eagerly.

The function can also return a command instead of a module, which defers
building it without moving it into another file:

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

await new Command()
  .name("example")
  .command("foo", () => new Command().description("Foo.").action(() => {}))
  .parse();
```

Both forms save the same thing: work that never happens. Cliffy calls the
function only for the sub command you invoked, so the commands on every other
branch are never built. How much that saves grows with the size of the tree and
with how much each command declares (options, arguments, environment variables).
A dynamic import skips all of that plus the module itself, which is what pays
off when one command pulls in dependencies the rest never touch.

Many sub commands next to each other are cheap, because they load together. A
lazy sub command nested inside another one is not: it can only load after its
parent, so with dynamic imports a command three levels down imports one level at
a time and the waits stack up. For a small tree, or commands that are cheap to
build, declaring them eagerly is still the simpler choice.

### The `env` option

Linking an option to an environment variable meant declaring both and keeping
the two in sync. The `option` method now takes an `env` option that declares the
variable with the option, reading into the same property with the precedence
`flag > env var > default`:

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

await new Command()
  .option("--install-root <path:string>", "Install location.", {
    env: { prefix: "DENO_" },
  })
  .parse();
```

`env: true` derives the name from the option, so `--install-root` reads
`INSTALL_ROOT`. A string sets it outright, `{ prefix }` prepends to the derived
name as above, and `{ type }` reads the variable as a different type than the
option itself.

A required option supplied only through the environment no longer throws as
missing.

### Negatable environment variables

`NO_COLOR=true` used to arrive as `{ noColor: true }`, which left every CLI to
invert it by hand. The `negatable` option on an environment variable strips the
`NO_` prefix and inverts the value, so it arrives as `{ color: false }`, the
same way `--no-color` behaves:

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

await new Command()
  .env("NO_COLOR=<value:boolean>", "Disable colors.", { negatable: true })
  .parse();
```

It is opt-in for backwards compatibility. In 2.0 this becomes the default for
boolean `NO_*` variables and the option goes away.

### The `presence` type

A new `presence` type treats a variable as a boolean based on whether it is set
at all, rather than on its value. Any non-empty value resolves to `true`, and an
unset or empty one adds nothing, which is the convention
[`NO_COLOR`](https://no-color.org) follows:

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

await new Command()
  .env("NO_CACHE=<value:presence>", "Disable the cache.", { negatable: true })
  .parse();
```

### The `enabled` option

`enabled` decides whether an option is registered at all, so you can put a flag
behind a feature switch or a platform check without building two different
commands:

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

await new Command()
  .option("--experimental", "Enable the experimental path.", {
    enabled: isFeatureEnabled("experimental"),
  })
  .parse();
```

The type follows. A literal `false` drops the key from the parsed options
entirely, while a runtime boolean like the one above widens the value to
`boolean | undefined`, since the option may never have been registered.

### Empty values where a value is required

An empty string is no longer accepted where a value is required. It is reported
the same way a missing one is:

```console
$ greet --name "" file.txt
error: Missing value for option "--name".

$ greet --name x ""
error: Missing argument: file
```

Optional options and arguments are unchanged: an empty value is still skipped,
so the default applies. Required ones were the inconsistency. An empty string
passed the check that a value exists, so a command could run with nothing where
it had declared that it needs something. To clear a value from the command line,
declare a [negatable option](/docs/latest/command/options#negatable-options).

### Color in the help output

The help output used to colorize regardless of where it was going. It now
follows the global color state, so `setColorEnabled(false)` turns it off, and
`NO_COLOR` switches it off whatever else is configured, even against an explicit
`colors: true`.

On top of `true` and `false`, the `colors` help option accepts a new `"auto"`
value. It adds a check that standard output is a terminal, so a piped or
redirected help stays free of escape codes:

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

await new Command()
  .name("deploy")
  .help({ colors: "auto" })
  .parse();
```

`"auto"` is opt-in here and becomes the default in 2.0.

## Upgrade

### The `@cliffy/upgrade` package

The upgrade API moved out of `@cliffy/command` into its own `@cliffy/upgrade`
package. The providers re-exported from `@cliffy/command` are deprecated, so
import them from the new package instead:

```ts
// before
import { JsrProvider } from "@cliffy/command/upgrade/provider/jsr";

// after
import { JsrProvider } from "@cliffy/upgrade/provider/jsr";
```

### Standalone binary self-upgrade

Until now `upgrade` reinstalled the CLI through a package manager, which a
compiled binary has no way to do. It can now replace the running executable with
a release asset instead, so a standalone build can update itself on a machine
with no runtime installed.

Being standalone is detected, not declared. Binaries built by `deno compile`, by
`bun build --compile`, or as Node single executable applications are all
recognised, so the same `upgrade` command does the right thing whether it runs
from a script or from a binary.

What you do have to declare is the asset to download, because that is what names
the file for each build target:

```ts
import { Command } from "@cliffy/command";
import { UpgradeCommand } from "@cliffy/command/upgrade";
import { GithubProvider } from "@cliffy/upgrade/provider/github";

await new Command()
  .command(
    "upgrade",
    new UpgradeCommand({
      provider: new GithubProvider({
        repository: "my-user/my-cli",
        asset: ({ os, arch }) => `my-cli-${os}-${arch}`,
      }),
    }),
  )
  .parse();
```

The resolver receives the CLI name, the resolved version, and a normalised `os`
and `arch`, so `darwin` and `x86_64` rather than each runtime's own spelling. An
`os-arch` to filename map works in place of the function.

Only the GitHub, GitLab and URL providers can do this, and only once an asset is
configured. Without one the upgrade reports that the registry does not support
upgrading a standalone executable. `standalone` forces the choice when detection
is not what you want, and `location` installs somewhere other than over the
running executable.

### The GitLab provider

CLIs released from a GitLab project can now upgrade from it, the same way the
GitHub provider works: tags and branches for a script install, a release asset
for a standalone binary. `host` points the provider at a self-hosted instance,
and a token for a private project is read from the `token` option or the
`GITLAB_TOKEN` environment variable:

```ts
import { GitlabProvider } from "@cliffy/upgrade/provider/gitlab";

const provider = new GitlabProvider({
  repository: "my-group/my-cli",
  host: "https://gitlab.example.com",
});
```

Apart from the generated source archives, a GitLab release holds no files of its
own, only links to them. The provider looks the asset up by name among those
links, so a build has to be linked on the release under the name that `asset`
resolves to. Where the file itself lives is up to you, the project's generic
package registry included.

### The URL provider

The URL provider upgrades from anywhere you can serve a file, with the URLs and
the version list under your control rather than a registry's. `asset` points at
a binary, and `url` at a script entrypoint on Deno:

```ts
import { UrlProvider } from "@cliffy/upgrade/provider/url";

const provider = new UrlProvider({
  asset: "https://example.com/my-cli",
  async versions() {
    const res = await fetch("https://example.com/my-cli/versions.json");
    return res.json();
  },
});
```

`versions` is what resolves the `latest` target, so without it an explicit
version has to be requested, and it takes either a list or a function returning
one. `asset` and `url` each take a string or a resolver, which is how you build
a URL per build target.

## Quality of life improvements

- Short-only options are named after their first short flag.
- Default values of arguments are shown in the help output.
- Help text values are formatted identically on every runtime.
- A standalone option no longer requires the command's arguments, so `--help`
  and `--version` work on a command that declares required ones.
- Upgrade errors the user can act on, an unknown version, a missing release
  asset, a provider that cannot do the requested kind of upgrade, are reported
  as validation errors instead of surfacing as internal ones.
- Every surplus argument is reported, not just the first.
- Expected arguments are matched and typed when parsing stops early.
- The GitHub provider returns plain branch names, instead of appending the
  "(Protected)" marker to the value it reports as a version.
- `getOs` normalises `win32` to `windows`.
- Bun no longer hangs after reading from stdin, and a top-level `await` was
  removed from `readSync`.
- A trailing `ESC [` is no longer parsed as shift and left.

## Fixed in 1.2.1

Shipped in the patch since 1.2.0: conflicting options only trigger when the
conflicting option was explicitly provided, so a default value no longer counts
as a conflict.

## Acknowledgements

This is the first release announced here rather than only on GitHub.
[Introducing the Cliffy blog](/blog/introducing-the-cliffy-blog) covers what
else lives on the blog.

Two of the parser fixes in this release came from
[#901](https://github.com/c4spar/cliffy/pull/901) and
[#902](https://github.com/c4spar/cliffy/pull/902) by Ian Hickson, which taught
the flags parser to match and type the expected arguments when parsing stops
early, and to report every surplus argument rather than only the first. The
`keycode` fix for a trailing `ESC [` came from
[#908](https://github.com/c4spar/cliffy/pull/908) by Rajan Pantha. Thank you
both.

Thank you also to everyone who filed the bug reports and questions that shaped
this release.

Contributions are welcome. The
[contributing guide](https://github.com/c4spar/cliffy/blob/main/CONTRIBUTING.md)
covers how to get started.
