Effect CLI
Installation
You can install @effect/cli
using your preferred package manager:
npm install @effect/cli
pnpm add @effect/cli
yarn add @effect/cli
You will also need to install one of the platform-specific @effect/platform
packages based on where you intend to run your command-line application.
This is because @effect/cli
must interact with many platform-specific services to function, such as the file system and the terminal.
For example, if your command-line application will run in a NodeJS environment:
npm install @effect/platform-node
pnpm add @effect/platform-node
yarn add @effect/platform-node
You can then provide the NodeContext.layer
exported from @effect/platform-node
to your command-line application to ensure that @effect/cli
has access to all the platform-specific services that it needs.
For a more detailed walkthrough, take a read through the Quick Start Guide below.
Built-In Options
All Effect CLI programs ship with several built-in options:
[--version]
- automatically displays the version of the CLI application[-h | --help]
- automatically generates and displays a help documentation for your CLI application[--wizard]
- starts the Wizard Mode for your CLI application which guides a user through constructing a command for your the CLI application[--shell-completion-script] [--shell-type]
- automatically generates and displays a shell completion script for your CLI application
API Reference
Quick Start Guide
In this quick start guide, we are going to attempt to replicate a small part of the Git Distributed Version Control System command-line interface (CLI) using @effect/cli
.
Specifically, our goal will be to build a CLI application which replicates the following subset of the git
CLI which we will call minigit
:
minigit [-v | --version] [-h | --help] [-c <name>=<value>]
minigit add [-v | --verbose] [--] [<pathspec>...]
minigit clone [--depth <depth>] [--] <repository> [<directory>]
NOTE: During this quick start guide, we will focus on building the components of the CLI application that will allow us to parse the above commands into structured data. However, implementing the functionality of these commands is out of the scope of this quick start guide.
The CLI application that will be built during this quick start guide is also available in the examples.
Creating the Command-Line Application
When building an CLI application with @effect/cli
, it is often good practice to specify each command individually is to consider what the data model should be for a parsed command.
For our minigit
CLI, we have three commands that we would like to model. Let's start by using @effect/cli
to create a basic Command
to represent our top-level minigit
command:
import * as Command from "@effect/cli/Command"
import * as Options from "@effect/cli/Options"
const minigitOptions = Options.keyValueMap("c").pipe(Options.optional)
const minigit = Command.make("minigit", { options: minigitOptions })
Some things to note in the above example:
- We've imported the
Command
and Options
modules from @effect/cli
- We've created an
Options
object which will allow us to parse key=value
pairs with the -c
flag - We've made our
-c
flag an optional option using the Options.optional
combinator - We've created a
Command
named minigit
and passed our previously created Options
to the minigit
command
An astute observer may have also noticed that in the snippet above we did not specify Options
for version and help.
This is because Effect CLI has several built-in options (see Built-In Options for more information) which are available automatically for all CLI applications built with @effect/cli
.
Let's continue and create our other two commands:
import * as Args from "@effect/cli/Args"
import * as Command from "@effect/cli/Command"
import * as Options from "@effect/cli/Options"
const minigitOptions = Options.keyValueMap("c").pipe(Options.optional)
const minigit = Command.make("minigit", { options: minigitOptions })
const minigitAddOptions = Options.boolean("verbose").pipe(Options.withAlias("v"))
const minigitAdd = Command.make("add", { options: minigitAddOptions })
const minigitCloneArgs = Args.all([
Args.text({ name: "repository" }),
Args.directory().pipe(Args.optional)
])
const minigitCloneOptions = Options.integer("depth").pipe(Options.optional)
const minigitClone = Command.make("clone", {
options: minigitCloneOptions,
args: minigitCloneArgs
})
Some things to note in the above example:
- We've additionally imported the
Args
module from @effect/cli
- We've used
Options.withAlias
to give the --verbose
flag an alias of -v
- We've used
Args.all
to compose two Args
allowing us to return a tuple of their values - We've additionally passed the
Args
for minigit clone
to the Command
Now that we've fully defined all the commands, we must indicate that the add
and clone
commands are subcommands of minigit
. This can be accomplished using the Command.withSubcommands
combinator:
const finalCommand = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone]))
Inspecting the type of finalCommand
above, we can see that @effect/cli
tracks:
- the name of the
Command
- the structured data that results from parsing the
Options
for the command - the structured data that results from parsing the
Args
for the command
const finalCommand: Command.Command<{
readonly name: "minigit";
readonly options: Option<HashMap<string, string>>;
readonly args: void;
readonly subcommand: Option<{
readonly name: "add";
readonly options: boolean;
readonly args: void;
} | {
readonly name: "clone";
readonly options: Option<number>;
readonly args: [string, Option<string>];
}>;
}>
To reduce the verbosity of the Command
type, we can create data models for our subcommands:
import * as Data from "effect/Data"
import * as Option from "effect/Option"
type Subcommand = AddSubcommand | CloneSubcommand
interface AddSubcommand extends Data.Case {
readonly _tag: "AddSubcommand"
readonly verbose: boolean
}
const AddSubcommand = Data.tagged<AddSubcommand>("AddSubcommand")
interface CloneSubcommand extends Data.Case {
readonly _tag: "CloneSubcommand"
readonly depth: Option.Option<number>
readonly repository: string
readonly directory: Option.Option<string>
}
const CloneSubcommand = Data.tagged<CloneSubcommand>("CloneSubcommand")
And then use Command.map
to map the values parsed by our subcommands to the data models we've created:
const minigitAdd = Command.make("add", { options: minigitAddOptions }).pipe(
Command.map((parsed) => AddSubcommand({ verbose: parsed.options }))
)
const minigitClone = Command.make("clone", {
options: minigitCloneOptions,
args: minigitCloneArgs
}).pipe(Command.map((parsed) =>
CloneSubcommand({
depth: parsed.options,
repository: parsed.args[0],
directory: parsed.args[1]
})
))
Now if we inspect the type of our top-level finalCommand
we will see our data models instead of their properties:
const finalCommand: Command.Command<{
readonly name: "minigit";
readonly options: Option.Option<HashMap<string, string>>;
readonly args: void;
readonly subcommand: Option.Option<CloneSubcommand | AddSubcommand>;
}>
The last thing left to do before we have a complete CLI application is to use our command to construct a CliApp
:
import * as CliApp from "@effect/cli/CliApp"
const cliApp = CliApp.make({
name: "MiniGit Distributed Version Control",
version: "v2.42.1",
command: finalCommand
})
Some things to note in the above example:
- We've additionally imported the
CliApp
module from @effect/cli
- We've constructed a new
CliApp
using CliApp.make
- We've provided our application with a
name
, version
, and our finalCommand
At this point, we're ready to run our CLI application.
Running the Command-Line Application
For the purposes of this example, we will assume that our CLI application is running in a NodeJS environment and that we have previously installed @effect/platform-node
(see Installation).
We can then run the CLI application using the CliApp.run
method. This method takes three arguments: the CliApp
to run, the command-line arguments, and an execute
function which will be called with the parsed command-line arguments.
import * as NodeContext from "@effect/platform-node/NodeContext"
import * as Runtime from "@effect/platform-node/Runtime"
import * as Effect from "effect/Effect"
const program = Effect.gen(function*(_) {
const args = yield* _(Effect.sync(() => globalThis.process.argv.slice(1)))
return yield* _(CliApp.run(cliApp, args, (parsed) => {
return Effect.unit
}))
})
program.pipe(
Effect.provide(NodeContext.layer),
Runtime.runMain
)
Some things to note in the above example:
- We've imported the
Effect
module from effect
- We've imported the
NodeContext
and Runtime
modules from @effect/platform-node
- We've used
Effect.sync
to lazily evaluate the NodeJS process.argv
- We've called
CliApp.run
to execute our CLI application (currently we're not using the parsed arguments) - We've provided our CLI
program
with the NodeContext
Layer
- Ensure that the CLI can access platform-specific services (i.e.
FileSystem
, Terminal
, etc.)
- We've used the platform-specific
Runtime.runMain
to run the program
At the moment, we're not using the parsed command-line arguments for anything, but we can run some of the built-in commands to see how they work. For simplicity, the example commands below run the minigit
example within this project. If you've been following along, feel free to replace with a command appropriate for your environment:
-
Display the CLI application's version:
pnpm tsx ./examples/minigit.ts --version
-
Display the help documentation for a command:
pnpm tsx ./examples/minigit.ts --help
pnpm tsx ./examples/minigit.ts add --help
pnpm tsx ./examples/minigit.ts clone --help
-
Run the Wizard Mode for a command:
pnpm tsx ./examples/minigit.ts --wizard
pnpm tsx ./examples/minigit.ts add --wizard
Let's go ahead and adjust our CliApp.run
to make use of the parsed command-line arguments.
import { pipe } from "effect/Function"
import * as ReadonlyArray from "effect/ReadonlyArray"
const handleRootCommand = (configs: Option.Option<HashMap.HashMap<string, string>>) =>
Option.match(configs, {
onNone: () => Console.log("Running 'minigit'"),
onSome: (configs) => {
const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join(", ")
return Console.log(`Running 'minigit' with the following configs: ${keyValuePairs}`)
}
})
const handleSubcommand = (subcommand: Subcommand) => {
switch (subcommand._tag) {
case "AddSubcommand": {
return Console.log(`Running 'minigit add' with '--verbose ${subcommand.verbose}'`)
}
case "CloneSubcommand": {
const optionsAndArgs = pipe(
ReadonlyArray.compact([
Option.map(subcommand.depth, (depth) => `--depth ${depth}`),
Option.some(subcommand.repository),
subcommand.directory
]),
ReadonlyArray.join(", ")
)
return Console.log(
`Running 'minigit clone' with the following options and arguments: '${optionsAndArgs}'`
)
}
}
}
const program = Effect.gen(function*(_) {
const args = yield* _(Effect.sync(() => globalThis.process.argv.slice(1)))
return yield* _(CliApp.run(cliApp, args, (parsed) =>
Option.match(parsed.subcommand, {
onNone: () => handleRootCommand(parsed.options),
onSome: (subcommand) => handleSubcommand(subcommand)
})))
})
program.pipe(
Effect.provide(NodeContext.layer),
Runtime.runMain
)
Some things to note in the above example:
- We've created functions to handle both cases where:
- We receive parsed command-line arguments that does contain a subcommand
- We receive parsed command-line arguments that does not contain a subcommand
- Within each of our handlers, we are simply loggging the command and the provided options / arguments to the console
We can now run some commands to observe the output. Again, we will assume you are running the example, so make sure to adjust the commands below if you've been following along:
pnpm tsx ./examples/minigit.ts
pnpm tsx ./examples/minigit.ts -c key1=value1 -c key2=value2
pnpm tsx ./examples/minigit.ts add
pnpm tsx ./examples/minigit.ts add --verbose
pnpm tsx ./examples/minigit.ts clone https://github.com/Effect-TS/cli.git
pnpm tsx ./examples/minigit.ts clone --depth 1 https://github.com/Effect-TS/cli.git
pnpm tsx ./examples/minigit.ts clone --depth 1 https://github.com/Effect-TS/cli.git ./output-directory
You should also try running some invalid commands and observe the error output from your Effect CLI application.
At this point, we've completed our quick-start guide!
We hope that you enjoyed learning a little bit about Effect CLI, but this guide only scratched surface! We encourage you to continue exploring Effect CLI and all the features it provides!
Happy Hacking!