Skip to content

Subcommands & Command Combinators

Real CLIs are usually a tree: git commit, git push, docker run, npm install. In Effect you build that tree by defining each child as its own Command, then attaching them to a parent with Command.withSubcommands. The parent acts as a namespace and a place to declare flags shared by all children.

The example below builds a tasks tool with create and list subcommands. The parent declares shared flags (--workspace, --verbose) that every subcommand can read, and each subcommand reads them by yield*-ing the parent command inside its handler.

tasks.ts
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Console, Effect } from "effect"
import { Argument, Command, Flag } from "effect/unstable/cli"
// Flags can be defined once and reused across commands.
const workspace = Flag.string("workspace").pipe(
Flag.withAlias("w"),
Flag.withDescription("Workspace to operate on"),
Flag.withDefault("personal")
)
// The root command. `withSharedFlags` makes these flags available to the root
// handler *and* to every descendant — and accepts them before or after a
// subcommand name (npm-style: `tasks --workspace x list` or `tasks list -w x`).
const tasks = Command.make("tasks").pipe(
Command.withSharedFlags({
workspace,
verbose: Flag.boolean("verbose").pipe(
Flag.withAlias("v"),
Flag.withDescription("Print diagnostic output")
)
}),
Command.withDescription("Track and manage tasks")
)
const create = Command.make(
"create",
{
title: Argument.string("title").pipe(Argument.withDescription("Task title")),
priority: Flag.choice("priority", ["low", "normal", "high"]).pipe(
Flag.withDescription("Priority for the new task"),
Flag.withDefault("normal")
)
},
Effect.fn(function*({ title, priority }) {
// Read the parent's shared flags by yielding the parent command. `root` is
// typed as `{ workspace: string; verbose: boolean }`.
const root = yield* tasks
if (root.verbose) {
yield* Console.log(`workspace=${root.workspace} action=create`)
}
yield* Console.log(
`Created "${title}" in ${root.workspace} with ${priority} priority`
)
})
).pipe(
Command.withDescription("Create a task"),
Command.withExamples([
{
command: 'tasks create "Ship 4.0" --priority high',
description: "Create a high-priority task"
}
])
)
const list = Command.make(
"list",
{
status: Flag.choice("status", ["open", "done", "all"]).pipe(
Flag.withDescription("Filter tasks by status"),
Flag.withDefault("open")
)
},
Effect.fn(function*({ status }) {
const root = yield* tasks
yield* Console.log(`Listing ${status} tasks in ${root.workspace}`)
})
).pipe(
Command.withDescription("List tasks"),
// Give the command a shorter alternative name: `tasks ls`.
Command.withAlias("ls")
)
// Attach the children, then run the whole tree as one executable.
tasks.pipe(
Command.withSubcommands([create, list]),
Command.run({ version: "1.0.0" }),
Effect.provide(NodeServices.layer),
NodeRuntime.runMain
)

A few invocations and what they do:

  • tasks --workspace team-a create "Fix bug" --priority high — runs create with workspace: "team-a".
  • tasks create "Fix bug" -w team-a — identical: shared flags are accepted after the subcommand name too.
  • tasks ls --status done — runs list via its alias.
  • tasks --help and tasks create --help — generated help for the tree and for a single subcommand.

A Command is itself an Effect whose success value is the parent’s parsed input. So inside a child handler, yield* parentCommand gives you back the parent’s shared flags, fully typed. This is type-safe in both directions:

  • Only flags declared with withSharedFlags are visible to children. Plain config on the parent stays local and is not inherited — a child cannot accidentally depend on it.
  • Effect tracks the dependency at the type level, so if a child yields a parent that was never wired up as its ancestor, the types won’t line up.

withSubcommands takes an array, and the result is just another Command, so you can nest arbitrarily deep — attach subcommands to a command that is itself a subcommand of something else. Effect handles selection (only the first non-flag token opens a subcommand), -- to stop option parsing, and friendly “did you mean…?” suggestions for misspelled subcommands automatically.

Because every handler is an ordinary Effect, a subcommand can do everything any Effect can: depend on services, acquire scoped resources, fail with typed errors, and read configuration. Provide those dependencies on the final Command.run Effect, or per-command with Command.provide.

Command.runWith is the same as Command.run but takes an explicit argument array instead of reading process.argv. That makes commands easy to exercise in tests without spawning a process.

import { Effect } from "effect"
import { Command } from "effect/unstable/cli"
const program = Effect.gen(function*() {
const run = Command.runWith(
tasks.pipe(Command.withSubcommands([create, list])),
{ version: "1.0.0" }
)
// Drive the CLI with explicit argv-style arrays.
yield* run(["--workspace", "team-a", "create", "Ship 4.0", "--priority", "high"])
yield* run(["list", "--status", "done"])
yield* run(["--help"])
})

Provide the platform services (NodeServices.layer, or a test-friendly Stdio layer) when you run program, just as you would in production.

The parser is permissive in the npm/commander tradition: flexible option placement, shared parent flags usable on either side of a subcommand, strict -- handling, and single-shot subcommand selection. The user-facing rules:

  • Shared parent flags work before or after the subcommand. Both tool --global install --pkg cowsay and tool install --pkg cowsay --global are valid when --global is declared with withSharedFlags.
  • Local parent flags are not inherited. A flag declared as plain config on the parent cannot appear on a subcommand path: tool --workspace docs chat fails if --workspace is local to tool.
  • Only the first value token opens a subcommand. In tool install pkg1 pkg2, install is the subcommand and pkg1 pkg2 are operands passed to it.
  • -- stops option parsing. Everything after -- is an operand: tool -- child --value x treats child --value x as operands and does not enter the child subcommand.
  • Options may appear before, after, or between operands (relaxed POSIX Guideline 9): tool copy --recursive src dest, tool copy src dest --recursive, and tool copy --recursive src dest --force are all equivalent.
  • Boolean flags default to true when present and support the canonical --no-<flag> negation: --verbose is true, --no-verbose is false. An optional boolean (Flag.optional(Flag.boolean("verbose"))) yields Option.none() when omitted, distinguishing it from an explicit false.
  • Repeated key=value flags merge into one map. tool --env foo=bar --env cool=dude parses to { foo: "bar", cool: "dude" }.
  • Unknown subcommands and options emit “did you mean?” suggestions. tool cpy suggests copy; tool --debugs suggests a near match. Commands marked with withHidden are excluded from these suggestions.
  • --version and --help take global precedence. They print and exit regardless of position or selected subcommand, so tool --version copy src dest prints the version and never runs copy.

Every combinator below takes a Command and returns a new one, so they all chain inside .pipe(...). They are pure: the command is only executed by Command.run / Command.runWith.

Creates a Command from a name, an optional Config of flags/arguments, and an optional handler. With no second argument it is an empty namespace command; with a config but no handler it parses input but has nothing to run yet; with a handler it is immediately runnable.

import { Console } from "effect"
import { Command, Flag } from "effect/unstable/cli"
// Name only — a namespace for subcommands.
const root = Command.make("app")
// Name + config — parses `--name` but has no handler yet.
const greet = Command.make("greet", { name: Flag.string("name") })
// Name + config + handler — runnable.
const hello = Command.make(
"hello",
{ name: Flag.string("name") },
({ name }) => Console.log(`Hello, ${name}!`)
)
// => `app hello --name Alice` logs "Hello, Alice!"

The config may be nested for organization; the handler receives the inferred shape (e.g. { server: { host: string } }).

Attaches (or replaces) the handler on a command created without one. Useful when the command and its handler are defined in different places.

import { Console } from "effect"
import { Command, Flag } from "effect/unstable/cli"
const greet = Command.make("greet", { name: Flag.string("name") })
const greetCmd = greet.pipe(
Command.withHandler(({ name }) => Console.log(`Hello, ${name}!`))
)
// => `greet --name Bob` logs "Hello, Bob!"

Attaches child commands, producing a hierarchical tree. Children may be passed as a flat array, or grouped via { group, commands } entries that the help formatter renders under a labeled heading. Duplicate flags across parent/child scopes are rejected.

import { Command } from "effect/unstable/cli"
const add = Command.make("add")
const remove = Command.make("remove")
const status = Command.make("status")
const git = Command.make("git").pipe(
Command.withSubcommands([
// Grouped entry — appears under "porcelain" in help output.
{ group: "porcelain", commands: [add, status] },
// Bare command — ungrouped.
remove
])
)
// => `git add`, `git status`, `git remove`

Adds flags that are inherited by every descendant. Unlike plain config, shared flags may be written before or after the subcommand name (npm-style) and are read inside child handlers via yield* parentCommand. Only flags are allowed — never positional arguments.

import { Console, Effect } from "effect"
import { Command, Flag } from "effect/unstable/cli"
const app = Command.make("app").pipe(
Command.withSharedFlags({ verbose: Flag.boolean("verbose") })
)
const build = Command.make("build", {}, () =>
Effect.gen(function*() {
const { verbose } = yield* app // read the shared parent flag
if (verbose) yield* Console.log("verbose build")
}))
const cli = app.pipe(Command.withSubcommands([build]))
// => `app --verbose build` and `app build --verbose` both enable verbose

Attaches GlobalFlag values that apply to the command and all of its descendants — for example a --log-level setting or a custom action flag. Settings are removed from the handler’s requirements once attached. See Global flags & completions for defining them.

import { Command, GlobalFlag } from "effect/unstable/cli"
// The built-in log-level setting flag, scoped to this command tree.
const app = Command.make("app").pipe(
Command.withGlobalFlags([GlobalFlag.LogLevel])
)
// => `app --log-level Debug ...` raises the minimum log level for the run

Sets the long description shown in the command’s own --help output.

import { Command } from "effect/unstable/cli"
const deploy = Command.make("deploy").pipe(
Command.withDescription("Deploy the application to a target environment")
)
// => shown at the top of `deploy --help`

Sets the one-line summary used when the command is listed among its parent’s subcommands and in shell completions. Falls back to the full description if not set.

import { Command } from "effect/unstable/cli"
const deploy = Command.make("deploy").pipe(
Command.withDescription("Deploy the application to a target environment, running migrations first"),
Command.withShortDescription("Deploy the app")
)
// => the parent's help lists: "deploy Deploy the app"

Adds an alternate name accepted during parsing and rendered as name, alias in help.

import { Command } from "effect/unstable/cli"
const list = Command.make("list").pipe(Command.withAlias("ls"))
// => both `app list` and `app ls` select the command

Hides a subcommand from parent help, shell completions, and “did you mean?” suggestions, while keeping it fully runnable by its exact name. Use for experimental or internal commands.

import { Command } from "effect/unstable/cli"
const experimental = Command.make("experimental").pipe(Command.withHidden)
const root = Command.make("app").pipe(
Command.withSubcommands([experimental])
)
// => `app experimental` runs, but it is absent from `app --help`

Attaches usage examples ({ command, description }) rendered in an EXAMPLES section of the command’s help.

import { Command } from "effect/unstable/cli"
const login = Command.make("login").pipe(
Command.withExamples([
{ command: "app login", description: "Log in with browser OAuth" },
{ command: "app login --token sbp_abc123", description: "Log in with a token" }
])
)
// => the two lines appear under EXAMPLES in `login --help`

Attaches a single piece of command-scoped metadata under a Context.Key. Useful for custom help formatters or other tooling. Re-adding the same key replaces the previous value.

import { Context } from "effect"
import { Command } from "effect/unstable/cli"
// A key for a custom "category" annotation, with a default value.
const Category = Context.Reference<string>("app/Category", {
defaultValue: () => "general"
})
const deploy = Command.make("deploy").pipe(
Command.annotate(Category, "ops")
)
// => deploy.annotations now carries Category = "ops"

Merges an already-built Context.Context of annotations into a command. On key conflicts, the incoming context wins.

import { Context } from "effect"
import { Command } from "effect/unstable/cli"
const Category = Context.Reference<string>("app/Category", {
defaultValue: () => "general"
})
const extra = Context.make(Category, "ops")
const deploy = Command.make("deploy").pipe(
Command.annotateMerge(extra)
)
// => merges all entries of `extra` onto the command at once

Supplies a Layer to a single command and its descendants, optionally built from the parsed input. The provided services are removed from the command’s requirement type. See services & layers.

import { Effect, FileSystem } from "effect"
import { Command, Flag } from "effect/unstable/cli"
const deploy = Command.make("deploy", { env: Flag.string("env") }, () =>
Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
// ...use fs
})).pipe(
// The layer can depend on the parsed input.
Command.provide((config) =>
config.env === "local"
? FileSystem.layerNoop({})
: FileSystem.layerNoop({})
)
)
// => FileSystem is satisfied per-command, chosen from --env

Provides a service synchronously, either as a constant value or computed from the parsed input.

import { Context, Effect } from "effect"
import { Command, Flag } from "effect/unstable/cli"
class Region extends Context.Service<Region, string>()("app/Region") {}
const deploy = Command.make("deploy", { region: Flag.string("region") }, () =>
Effect.gen(function*() {
const region = yield* Region
// ...
})).pipe(
Command.provideSync(Region, (config) => config.region)
)
// => Region service is set from the --region flag

Acquires a service effectfully (optionally from the parsed input) before running the handler. The effect’s errors and requirements join the command’s.

import { Context, Effect } from "effect"
import { Command, Flag } from "effect/unstable/cli"
class Token extends Context.Service<Token, string>()("app/Token") {}
const deploy = Command.make("deploy", { env: Flag.string("env") }, () =>
Effect.gen(function*() {
const token = yield* Token
// ...
})).pipe(
Command.provideEffect(Token, (config) =>
Effect.succeed(`token-for-${config.env}`))
)
// => Token is computed effectfully each run, before the handler

Runs an effect (optionally from the parsed input) before the handler without providing any service — handy for setup, validation, or logging.

import { Console, Effect } from "effect"
import { Command, Flag } from "effect/unstable/cli"
const deploy = Command.make("deploy", { env: Flag.string("env") }, () =>
Console.log("deploying")).pipe(
Command.provideEffectDiscard((config) =>
Console.log(`Preparing to deploy to ${config.env}`))
)
// => logs the "Preparing..." line first, then runs the handler

Runs a command using the arguments from the Stdio service (i.e. process.argv on Node). Pass a version string to wire up the built-in --version flag. This is the normal application entry point.

import { Console } from "effect"
import { Command, Flag } from "effect/unstable/cli"
const greet = Command.make("greet", { name: Flag.string("name") }, ({ name }) =>
Console.log(`Hello, ${name}!`))
const program = greet.pipe(Command.run({ version: "1.0.0" }))
// => an Effect; run it with NodeRuntime.runMain and NodeServices.layer

Like run, but takes an explicit ReadonlyArray<string> of arguments instead of reading Stdio. This is the preferred entry point for tests.

import { Console, Effect } from "effect"
import { Command, Flag } from "effect/unstable/cli"
const greet = Command.make("greet", { name: Flag.string("name") }, ({ name }) =>
Console.log(`Hello, ${name}!`))
const test = Effect.gen(function*() {
const run = Command.runWith(greet, { version: "1.0.0" })
yield* run(["--name", "Alice"]) // logs "Hello, Alice!"
yield* run(["--help"]) // prints help
yield* run(["--version"]) // prints "1.0.0"
})

A type guard that returns true if a value is a Command. It checks for the command type-id, not the full shape.

import { Command } from "effect/unstable/cli"
Command.isCommand(Command.make("app")) // => true
Command.isCommand({ name: "app" }) // => false