cli
cli copied to clipboard
Rapidly build powerful and composable command-line applications
⚠️ @effect/cli has moved
This repository has been deprecated following the consolidation of its codebase into the effect monorepo.
You can find @effect/cli here: https://github.com/effect-ts/effect/tree/main/packages/cli
Effect CLI
- ⚠️ @effect/cli has moved
- Effect CLI
- Installation
- Built-In Options
- API Reference
- Tutorial
- Creating the Command-Line Application
- Our First Command
- Creating Subcommands
- Creating the CLI Application
- Running the CLI Application
- Executing Built-In Options
- Executing User-Defined Commands
- Accessing Parent Arguments in Subcommands
- Conclusion
- Creating the Command-Line Application
- FAQ
- Command-Line Argument Parsing Specification
Installation
You can install @effect/cli using your preferred package manager:
npm install @effect/cli
# or
pnpm add @effect/cli
# or
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
# or
pnpm add @effect/platform-node
# or
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 Tutorial below.
Built-In Options
All Effect CLI programs ship with several built-in options:
[--completions (bash | sh | fish | zsh)]- automatically generates and displays a shell completion script for your CLI application[-h | --help]- automatically generates and displays a help documentation for your CLI application[--version]- automatically displays the version of the CLI application[--wizard]- starts the Wizard Mode for your CLI application which guides a user through constructing a command for your the CLI application
API Reference
- https://effect-ts.github.io/cli/docs/modules
Tutorial
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 tutorial is also available in the examples.
Creating the Command-Line Application
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.
The Command.make constructor creates a Command from a name, a Command Config object, and a Command handler, which is a function that receives the parsed Config and actually executes the Command. Each of these parameters is also reflected in the type signature of Command:
Command<Name extends string, R, E, A> has four type arguments:
Name extends string: the name of the commandR: the environment required by theCommand's handlerE: the expected errors returned by theCommand's handlerA: the parsedConfigobject provided to theCommand's handler
Let's take a look at each of parameter in more detail:
Command Name
The first parameter to Command.make is the name of the Command. This is the name that will be used to parse the Command from the command-line arguments.
For example, if we have a CLI application called my-cli-app with a single subcommand named foo, then executing the following command will run the foo Command in your CLI application:
my-cli-app foo
Command Configuration
The second parameter to Command.make is the Command Config. The Config is an object of key/value pairs where the keys are just identifiers and the values are the Options and Args that the Command may receive. The Config object can have nested Config objects or arrays of Config objects.
When the CLI application is actually executed, the Command Config is parsed from the command-line options and arguments following the Command name.
Command Handler
The Command handler is an effectful function that receives the parsed Config and returns an Effect. This allows the user to execute the code associated with their Command with the full power of Effect.
Our First Command
Returning to our minigit CLI application, let's use what we've learned about Command.make to create the top-level minigit Command:
import { Command, Options } from "@effect/cli"
import { Console, Effect, Option } from "effect"
// minigit [--version] [-h | --help] [-c <name>=<value>]
const configs = Options.keyValueMap("c").pipe(Options.optional)
const minigit = Command.make("minigit", { configs }, ({ configs }) =>
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}`)
}
}))
Some things to note in the above example:
- We've imported the
CommandandOptionsmodules from@effect/cli - We've also imported the
ConsoleandOptionmodules from the coreeffectpackage - We've created an
Optionsobject which will allow us to parsekey=valuepairs with the-cflag - We've made our
-cflag an optional option using theOptions.optionalcombinator - We've created a
Commandnamedminigitand passedconfigsOptionsto theminigitcommandConfig - We've utilized the parsed
CommandConfigforminigitto execute code based upon whether the optional-cflag was provided
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.
Creating Subcommands
Let's continue with our minigit example and and create the add and clone subcommands:
import { Args, Command, Options } from "@effect/cli"
import { Console, Option, ReadonlyArray } from "effect"
// minigit [--version] [-h | --help] [-c <name>=<value>]
const configs = Options.keyValueMap("c").pipe(Options.optional)
const minigit = Command.make("minigit", { configs }, ({ configs }) =>
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}`)
}
}))
// minigit add [-v | --verbose] [--] [<pathspec>...]
const pathspec = Args.text({ name: "pathspec" }).pipe(Args.repeated)
const verbose = Options.boolean("verbose").pipe(Options.withAlias("v"))
const minigitAdd = Command.make("add", { pathspec, verbose }, ({ pathspec, verbose }) => {
const paths = ReadonlyArray.match(pathspec, {
onEmpty: () => "",
onNonEmpty: (paths) => ` ${ReadonlyArray.join(paths, " ")}`
})
return Console.log(`Running 'minigit add${paths}' with '--verbose ${verbose}'`)
})
// minigit clone [--depth <depth>] [--] <repository> [<directory>]
const minigitClone = Command.make("clone", { repository, directory, depth }, (config) => {
const depth = Option.map(config.depth, (depth) => `--depth ${depth}`)
const repository = Option.some(config.repository)
const optionsAndArgs = ReadonlyArray.getSomes([depth, repository, config.directory])
return Console.log(
"Running 'minigit clone' with the following options and arguments: " +
`'${ReadonlyArray.join(optionsAndArgs, ", ")}'`
)
})
Some things to note in the above example:
- We've additionally imported the
Argsmodule from@effect/cliand theReadonlyArraymodule fromeffect - We've used the
Argsmodule to specify some positional arguments for ouraddandclonesubcommands - We've used
Options.withAliasto give the--verboseflag an alias of-vfor ouraddsubcommand
Creating the CLI Application
Now that we've specified all the Commands our application can handle, let's compose them together so that we can actually run the CLI 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).
Our final CLI application is as follows:
import { Args, Command, Options } from "@effect/cli"
import { NodeContext, Runtime } from "@effect/platform-node"
import { Console, Effect, Option, ReadonlyArray } from "effect"
// minigit [--version] [-h | --help] [-c <name>=<value>]
const configs = Options.keyValueMap("c").pipe(Options.optional)
const minigit = Command.make("minigit", { configs }, ({ configs }) =>
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}`)
}
}))
// minigit add [-v | --verbose] [--] [<pathspec>...]
const pathspec = Args.text({ name: "pathspec" }).pipe(Args.repeated)
const verbose = Options.boolean("verbose").pipe(Options.withAlias("v"))
const minigitAdd = Command.make("add", { pathspec, verbose }, ({ pathspec, verbose }) => {
const paths = ReadonlyArray.match(pathspec, {
onEmpty: () => "",
onNonEmpty: (paths) => ` ${ReadonlyArray.join(paths, " ")}`
})
return Console.log(`Running 'minigit add${paths}' with '--verbose ${verbose}'`)
})
// minigit clone [--depth <depth>] [--] <repository> [<directory>]
const minigitClone = Command.make("clone", { repository, directory, depth }, (config) => {
const depth = Option.map(config.depth, (depth) => `--depth ${depth}`)
const repository = Option.some(config.repository)
const optionsAndArgs = ReadonlyArray.getSomes([depth, repository, config.directory])
return Console.log(
"Running 'minigit clone' with the following options and arguments: " +
`'${ReadonlyArray.join(optionsAndArgs, ", ")}'`
)
})
const command = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone]))
const cli = Command.run(command, {
name: "Minigit Distributed Version Control",
version: "v1.0.0"
})
Effect.suspend(() => cli(process.argv.slice(2))).pipe(
Effect.provide(NodeContext.layer),
Runtime.runMain
)
Some things to note in the above example:
- We've additionally imported the
Effectmodule fromeffect - We've also imported the
RuntimeandNodeContextmodules from@effect/platform-node - We've used
Command.withSubcommandsto add ouraddandclonecommands as subcommands ofminigit - We've used
Command.runto create aCliAppwith anameand aversion - We've used
Effect.suspendto lazily evaluateprocess.argv, passing all but the first two command-line arguments to our CLI application- Note: we've sliced off the first two command-line arguments because we assume that our CLI will be run using:
node ./my-cli.js ... - Make sure to adjust for your own use case
- Note: we've sliced off the first two command-line arguments because we assume that our CLI will be run using:
Running the CLI Application
At this point, we're ready to run our CLI application!
Let's assume that we've bundled our CLI into a single file called minigit.js. However, if you are following along using the minigit example in this repository, you can run the same commands with pnpm tsx ./examples/minigit.ts ....
Executing Built-In Options
Let's start by getting the version of our CLi application using the built-in --version option.
> node ./minigit.js --version
v1.0.0
We can also print out help documentation for each of our application's commands using the -h | --help built-in option.
For example, running the top-level command with --help produces the following output:
> node ./minigit.js --help
Minigit Distributed Version Control v1.0.0
USAGE
$ minigit [-c text]
OPTIONS
-c text
A user-defined piece of text.
This setting is a property argument which:
- May be specified a single time: '-c key1=value key2=value2'
- May be specified multiple times: '-c key1=value -c key2=value2'
This setting is optional.
COMMANDS
- add [(-v, --verbose)] <pathspec>...
- clone [--depth integer] <repository> [<directory>]
Running the add subcommand with --help produces the following output:
> node ./minigit.js add --help
Minigit Distributed Version Control v1.0.0
USAGE
$ add [(-v, --verbose)] <pathspec>...
ARGUMENTS
<pathspec>...
A user-defined piece of text.
This argument may be repeated zero or more times.
OPTIONS
(-v, --verbose)
A true or false value.
This setting is optional.
Executing User-Defined Commands
We can also experiment with executing our own commands:
> node ./minigit.js add .
Running 'minigit add .' with '--verbose false'
> node ./minigit.js add --verbose .
Running 'minigit add .' with '--verbose true'
> node ./minigit.js clone --depth 1 https://github.com/Effect-TS/cli.git
Running 'minigit clone' with the following options and arguments: '--depth 1, https://github.com/Effect-TS/cli.git'
Accessing Parent Arguments in Subcommands
In certain scenarios, you may want your subcommands to have access to Options / Args passed to their parent commands.
Because Command is also a subtype of Effect, you can directly Effect.map, Effect.flatMap a parent command in a subcommand's handler to extract it's Config.
For example, let's say that our minigit clone subcommand needs access to the configuration parameters that can be passed to the parent minigit command via minigit -c key=value. We can accomplish this by adjusting our clone Command handler to Effect.flatMap the parent minigit:
const repository = Args.text({ name: "repository" })
const directory = Args.directory().pipe(Args.optional)
const depth = Options.integer("depth").pipe(Options.optional)
const minigitClone = Command.make("clone", { repository, directory, depth }, (subcommandConfig) =>
// By using `Effect.flatMap` on the parent command, we get access to it's parsed config
Effect.flatMap(minigit, (parentConfig) => {
const depth = Option.map(subcommandConfig.depth, (depth) => `--depth ${depth}`)
const repository = Option.some(subcommandConfig.repository)
const optionsAndArgs = ReadonlyArray.getSomes([depth, repository, subcommandConfig.directory])
const configs = Option.match(parentConfig.configs, {
onNone: () => "",
onSome: (map) => Array.from(map).map(([key, value]) => `${key}=${value}`).join(", ")
})
return Console.log(
"Running 'minigit clone' with the following options and arguments: " +
`'${ReadonlyArray.join(optionsAndArgs, ", ")}'\n` +
`and the following configuration parameters: ${configs}`
)
})
)
In addition, accessing a parent command in the handler of a subcommand will add the parent Command to the environment of the subcommand.
We can directly observe this by inspecting the type of minigitClone after accessing the parent command:
const minigitClone: Command.Command<
"clone",
// The parent `minigit` command has been added to the environment required by
// the subcommand's handler
Command.Command.Context<"minigit">,
never,
{
readonly repository: string;
readonly directory: Option.Option<string>;
readonly depth: Option.Option<number>;
}
>
The parent command will be "erased" from the subcommand's environment when using Command.withSubcommands:
const command = minigit.pipe(Command.withSubcommands([minigitClone]))
// ^? Command<"minigit", never, ..., ...>
We can run the command with some configuration parameters to see the final result:
> node ./minigit.js -c key1=value1 clone --depth 1 https://github.com/Effect-TS/cli.git
Running 'minigit clone' with the following options and arguments: '--depth 1, https://github.com/Effect-TS/cli.git'
and the following configuration parameters: key1=value1
Conclusion
At this point, we've completed our tutorial!
We hope that you enjoyed learning a little bit about Effect CLI, but this tutorial has only scratched surface!
We encourage you to continue exploring Effect CLI and all the features it provides!
Happy Hacking!
FAQ
Command-Line Argument Parsing Specification
The internal command-line argument parser operates under the following specifications:
-
By default, the
Options/Argsof a command are only recognized before subcommands# -v is an option for program program -v subcommand # -v is an option for subcommand program subcommand -v -
The
Optionsfor aCommandare always parsed before positionalArgs# valid program --option arg # invalid program arg --option -
Excess arguments after the command-line is fully processed results in a
ValidationError