Skip to content

Subcommands

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.