From 697a292f4b42173990513892f8188b00b072c281 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Fri, 1 Dec 2023 14:02:01 -0500 Subject: [PATCH] Add tutorial to README (#401) --- README.md | 541 +++++++++++++++++++++++----------------- examples/minigit.ts | 121 ++++----- src/Command.ts | 14 +- src/internal/command.ts | 16 +- 4 files changed, 384 insertions(+), 308 deletions(-) diff --git a/README.md b/README.md index 8ffdfa1..c799298 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,18 @@ - [Installation](#installation) - [Built-In Options](#built-in-options) - [API Reference](#api-reference) - - [Quick Start Guide](#quick-start-guide) + - [Tutorial](#tutorial) - [Creating the Command-Line Application](#creating-the-command-line-application) - - [Running the Command-Line Application](#running-the-command-line-application) + - [Our First Command](#our-first-command) + - [Creating Subcommands](#creating-subcommands) + - [Creating the CLI Application](#creating-the-cli-application) + - [Running the CLI Application](#running-the-cli-application) + - [Executing Built-In Options](#executing-built-in-options) + - [Executing User-Defined Commands](#executing-user-defined-commands) + - [Accessing Parent Arguments in Subcommands](#accessing-parent-arguments-in-subcommands) + - [Conclusion](#conclusion) + - [FAQ](#faq) + - [Command-Line Argument Parsing Specification](#command-line-argument-parsing-specification) ## Installation @@ -37,22 +46,22 @@ 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](#quick-start-guide) below. +For a more detailed walkthrough, take a read through the [Tutorial](#tutorial) below. ## Built-In Options All Effect CLI programs ship with several built-in options: - - `[--version]` - automatically displays the version of the CLI application + - `[--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 - - `[--shell-completion-script] [--shell-type]` - automatically generates and displays a shell completion script for your CLI application ## API Reference - https://effect-ts.github.io/cli/docs/modules -## Quick Start Guide +## 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`. @@ -66,301 +75,389 @@ minigit clone [--depth ] [--] [] **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](./examples/git.ts). +The CLI application that will be built during this tutorial is also available in the [examples](./examples/minigit.ts). ### 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. + +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` has four type arguments: + - `Name extends string`: the name of the command + - `R`: the environment required by the `Command`'s handler + - `E`: the expected errors returned by the `Command`'s handler + - `A`: the parsed `Config` object provided to the `Command`'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: + +```sh +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 -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: +Returning to our `minigit` CLI application, let's use what we've learned about `Command.make` to create the top-level `minigit` `Command`: ```ts -import * as Command from "@effect/cli/Command" -import * as Options from "@effect/cli/Options" +import { Command, Options } from "@effect/cli" +import { Console, Effect, Option } from "effect" // minigit [--version] [-h | --help] [-c =] -const minigitOptions = Options.keyValueMap("c").pipe(Options.optional) -const minigit = Command.make("minigit", { options: minigitOptions }) +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: 1. We've imported the `Command` and `Options` modules from `@effect/cli` - 2. We've created an `Options` object which will allow us to parse `key=value` pairs with the `-c` flag - 3. We've made our `-c` flag an optional option using the `Options.optional` combinator - 4. We've created a `Command` named `minigit` and passed our previously created `Options` to the `minigit` command + 2. We've also imported the `Console` and `Option` modules from the core `effect` package + 3. We've created an `Options` object which will allow us to parse `key=value` pairs with the `-c` flag + 4. We've made our `-c` flag an optional option using the `Options.optional` combinator + 5. We've created a `Command` named `minigit` and passed `configs` `Options` to the `minigit` command `Config` + 6. We've utilized the parsed `Command` `Config` for `minigit` to execute code based upon whether the optional `-c` flag 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](#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: +#### Creating Subcommands + +Let's continue with our `minigit` example and and create the `add` and `clone` subcommands: ```ts -import * as Args from "@effect/cli/Args" -import * as Command from "@effect/cli/Command" -import * as Options from "@effect/cli/Options" +import { Args, Command, Options } from "@effect/cli" +import { Console, Option, ReadonlyArray } from "effect" // minigit [--version] [-h | --help] [-c =] -const minigitOptions = Options.keyValueMap("c").pipe(Options.optional) -const minigit = Command.make("minigit", { options: minigitOptions }) +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] [--] [...] -const minigitAddOptions = Options.boolean("verbose").pipe(Options.withAlias("v")) -const minigitAdd = Command.make("add", { options: minigitAddOptions }) +// minigit add [-v | --verbose] [--] [...] +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 ] [--] [] -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 +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: - 1. We've additionally imported the `Args` module from `@effect/cli` - 2. We've used `Options.withAlias` to give the `--verbose` flag an alias of `-v` - 3. We've used `Args.all` to compose two `Args` allowing us to return a tuple of their values - 4. We've additionally passed the `Args` for `minigit clone` to the `Command` + 1. We've additionally imported the `Args` module from `@effect/cli` and the `ReadonlyArray` module from `effect` + 2. We've used the `Args` module to specify some positional arguments for our `add` and `clone` subcommands + 3. We've used `Options.withAlias` to give the `--verbose` flag an alias of `-v` for our `add` subcommand -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: -```ts -const finalCommand = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone])) -``` +#### Creating the CLI Application -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 +Now that we've specified all the `Command`s our application can handle, let's compose them together so that we can actually run the CLI application. -```ts -const finalCommand: Command.Command<{ - readonly name: "minigit"; - readonly options: Option>; - readonly args: void; - readonly subcommand: Option<{ - readonly name: "add"; - readonly options: boolean; - readonly args: void; - } | { - readonly name: "clone"; - readonly options: Option; - readonly args: [string, Option]; - }>; -}> -``` +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](#installation)). -To reduce the verbosity of the `Command` type, we can create data models for our subcommands: +Our final CLI application is as follows: ```ts -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") - -interface CloneSubcommand extends Data.Case { - readonly _tag: "CloneSubcommand" - readonly depth: Option.Option - readonly repository: string - readonly directory: Option.Option -} -const CloneSubcommand = Data.tagged("CloneSubcommand") -``` +import { Args, Command, Options } from "@effect/cli" +import { NodeContext, Runtime } from "@effect/platform-node" +import { Console, Effect, Option, ReadonlyArray } from "effect" -And then use `Command.map` to map the values parsed by our subcommands to the data models we've created: +// minigit [--version] [-h | --help] [-c =] +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}`) + } + })) -```ts -const minigitAdd = Command.make("add", { options: minigitAddOptions }).pipe( - Command.map((parsed) => AddSubcommand({ verbose: parsed.options })) +// minigit add [-v | --verbose] [--] [...] +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 ] [--] [] +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: + 1. We've additionally imported the `Effect` module from `effect` + 2. We've also imported the `Runtime` and `NodeContext` modules from `@effect/platform-node` + 3. We've used `Command.withSubcommands` to add our `add` and `clone` commands as subcommands of `minigit` + 4. We've used `Command.run` to create a `CliApp` with a `name` and a `version` + 5. We've used `Effect.suspend` to lazily evaluate `process.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 + +#### 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](./examples/minigit.ts) 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. -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] - }) -)) +``` +> node ./minigit.js --version +v1.0.0 ``` -Now if we inspect the type of our top-level `finalCommand` we will see our data models instead of their properties: +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: -```ts -const finalCommand: Command.Command<{ - readonly name: "minigit"; - readonly options: Option.Option>; - readonly args: void; - readonly subcommand: Option.Option; -}> ``` +> node ./minigit.js --help +Minigit Distributed Version Control v1.0.0 -The last thing left to do before we have a complete CLI application is to use our command to construct a `CliApp`: +USAGE -```ts -import * as CliApp from "@effect/cli/CliApp" +$ minigit [-c text] -// ... +OPTIONS -const cliApp = CliApp.make({ - name: "MiniGit Distributed Version Control", - version: "v2.42.1", - command: finalCommand -}) -``` +-c text -Some things to note in the above example: - 1. We've additionally imported the `CliApp` module from `@effect/cli` - 2. We've constructed a new `CliApp` using `CliApp.make` - 3. We've provided our application with a `name`, `version`, and our `finalCommand` + A user-defined piece of text. -At this point, we're ready to run our CLI application. + This setting is a property argument which: -### Running the Command-Line Application + - May be specified a single time: '-c key1=value key2=value2' -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](#installation)). + - May be specified multiple times: '-c key1=value -c key2=value2' -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. + This setting is optional. -```ts -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 // For now, do nothing - })) -}) +COMMANDS -program.pipe( - Effect.provide(NodeContext.layer), - Runtime.runMain -) + - add [(-v, --verbose)] ... + + - clone [--depth integer] [] ``` -Some things to note in the above example: - 1. We've imported the `Effect` module from `effect` - 2. We've imported the `NodeContext` and `Runtime` modules from `@effect/platform-node` - 3. We've used `Effect.sync` to lazily evaluate the NodeJS `process.argv` - 4. We've called `CliApp.run` to execute our CLI application (currently we're not using the parsed arguments) - 5. We've provided our CLI `program` with the `NodeContext` `Layer` - - Ensure that the CLI can access platform-specific services (i.e. `FileSystem`, `Terminal`, etc.) - 6. We've used the platform-specific `Runtime.runMain` to run the program +Running the `add` subcommand with `--help` produces the following output: -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](./examples/minigit.ts) within this project. If you've been following along, feel free to replace with a command appropriate for your environment: +``` +> node ./minigit.js add --help +Minigit Distributed Version Control v1.0.0 - - Display the CLI application's version: +USAGE - ```sh - pnpm tsx ./examples/minigit.ts --version - ``` +$ add [(-v, --verbose)] ... - - Display the help documentation for a command: +ARGUMENTS - ```sh - 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: + A user-defined piece of text. - ```sh - pnpm tsx ./examples/minigit.ts --wizard - pnpm tsx ./examples/minigit.ts add --wizard - ``` + This argument may be repeated zero or more times. -Let's go ahead and adjust our `CliApp.run` to make use of the parsed command-line arguments. +OPTIONS -```ts -import { pipe } from "effect/Function" -import * as ReadonlyArray from "effect/ReadonlyArray" +(-v, --verbose) -const handleRootCommand = (configs: Option.Option>) => - 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}`) - } - }) + A true or false value. -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) - }))) -}) + This setting is optional. +``` -program.pipe( - Effect.provide(NodeContext.layer), - Runtime.runMain +##### 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`: + +```ts +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}` + ) + }) ) ``` -Some things to note in the above example: - 1. 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 - 2. Within each of our handlers, we are simply loggging the command and the provided options / arguments to the console +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 now run some commands to observe the output. Again, we will assume you are running the [example](./examples//minigit.ts), so make sure to adjust the commands below if you've been following along: +We can directly observe this by inspecting the type of `minigitClone` after accessing the parent command: -```sh -pnpm tsx ./examples/minigit.ts -pnpm tsx ./examples/minigit.ts -c key1=value1 -c key2=value2 +```ts +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; + readonly depth: Option.Option; + } +> +``` + +The parent command will be "erased" from the subcommand's environment when using `Command.withSubcommands`: + +```ts +const command = minigit.pipe(Command.withSubcommands([minigitClone])) +// ^? Command<"minigit", never, ..., ...> +``` -pnpm tsx ./examples/minigit.ts add -pnpm tsx ./examples/minigit.ts add --verbose +We can run the command with some configuration parameters to see the final result: -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 +``` +> 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 ``` -You should also try running some invalid commands and observe the error output from your Effect CLI application. +### Conclusion -
+At this point, we've completed our tutorial! -At this point, we've completed our quick-start guide! +We hope that you enjoyed learning a little bit about Effect CLI, but this tutorial has only scratched surface! -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! +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: + + 1. By default, the `Options` / `Args` of a command are only recognized _before_ subcommands + + ```sh + # -v is an option for program + program -v subcommand + # -v is an option for subcommand + program subcommand -v + ``` + + 2. The `Options` for a `Command` are _always_ parsed before positional `Args` + ```sh + # valid + program --option arg + # invalid + program arg --option + ``` + + 3. Excess arguments after the command-line is fully processed results in a `ValidationError` diff --git a/examples/minigit.ts b/examples/minigit.ts index d32a263..046b1dd 100644 --- a/examples/minigit.ts +++ b/examples/minigit.ts @@ -1,84 +1,63 @@ -import * as Args from "@effect/cli/Args" -import * as Command from "@effect/cli/Command" -import * as Options from "@effect/cli/Options" -import * as NodeContext from "@effect/platform-node/NodeContext" -import * as Runtime from "@effect/platform-node/Runtime" -import * as Console from "effect/Console" -import * as Effect from "effect/Effect" -import { pipe } from "effect/Function" -import * as Option from "effect/Option" -import * as ReadonlyArray from "effect/ReadonlyArray" +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 =] -const minigit = Command.make( - "minigit", - { configs: Options.keyValueMap("c").pipe(Options.optional) }, - ({ 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}`) - } - }) -) - -const configsString = Effect.map( - minigit, - ({ configs }) => - Option.match(configs, { - onNone: () => "", - onSome: (configs) => { - const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join( - ", " - ) - return ` with the following configs: ${keyValuePairs}` - } - }) -) - -// minigit add [-v | --verbose] [--] [...] -const minigitAdd = Command.make("add", { - verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) -}, ({ verbose }) => - Effect.gen(function*(_) { - const configs = yield* _(configsString) - yield* _(Console.log(`Running 'minigit add' with '--verbose ${verbose}'${configs}`)) +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 clone [--depth ] [--] [] -const minigitClone = Command.make("clone", { - repository: Args.text({ name: "repository" }), - directory: Args.directory().pipe(Args.optional), - depth: Options.integer("depth").pipe(Options.optional) -}, ({ depth, directory, repository }) => { - const optionsAndArgs = pipe( - ReadonlyArray.getSomes([ - Option.map(depth, (depth) => `--depth ${depth}`), - Option.some(repository), - directory - ]), - ReadonlyArray.join(", ") - ) - return Console.log( - `Running 'minigit clone' with the following options and arguments: '${optionsAndArgs}'` - ) +// minigit add [-v | --verbose] [--] [...] +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}'`) }) -const finalCommand = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone])) +// minigit clone [--depth ] [--] [] +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) => + 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}` + ) + }) +) -// ============================================================================= -// Application -// ============================================================================= +const command = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone])) -const run = Command.run(finalCommand, { - name: "MiniGit Distributed Version Control", - version: "v2.42.1" +const cli = Command.run(command, { + name: "Minigit Distributed Version Control", + version: "v1.0.0" }) -Effect.suspend(() => run(process.argv.slice(2))).pipe( +Effect.suspend(() => cli(process.argv.slice(2))).pipe( Effect.provide(NodeContext.layer), Runtime.runMain ) diff --git a/src/Command.ts b/src/Command.ts index 951cca1..9c8c6a1 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -65,19 +65,19 @@ export declare namespace Command { * @since 1.0.0 * @category models */ - export interface ConfigBase { + export interface Config { readonly [key: string]: | Args | Options - | ReadonlyArray | Options | ConfigBase> - | ConfigBase + | ReadonlyArray | Options | Config> + | Config } /** * @since 1.0.0 * @category models */ - export type ParseConfig = Types.Simplify< + export type ParseConfig = Types.Simplify< { readonly [Key in keyof A]: ParseConfigValue } > @@ -85,7 +85,7 @@ export declare namespace Command { { readonly [Key in keyof A]: ParseConfigValue } : A extends Args ? Value : A extends Options ? Value - : A extends ConfigBase ? ParseConfig + : A extends Config ? ParseConfig : never interface ParsedConfigTree { @@ -209,7 +209,7 @@ export const make: { {} > - ( + ( name: Name, config: Config ): Command< @@ -219,7 +219,7 @@ export const make: { Types.Simplify> > - ( + ( name: Name, config: Config, handler: (_: Types.Simplify>) => Effect diff --git a/src/internal/command.ts b/src/internal/command.ts index 62c357c..6d08a13 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -34,14 +34,14 @@ export const TypeId: Command.TypeId = Symbol.for( CommandSymbolKey ) as Command.TypeId -const parseConfig = (config: Command.Command.ConfigBase): Command.Command.ParsedConfig => { +const parseConfig = (config: Command.Command.Config): Command.Command.ParsedConfig => { const args: Array> = [] let argsIndex = 0 const options: Array> = [] let optionsIndex = 0 const tree: Command.Command.ParsedConfigTree = {} - function parse(config: Command.Command.ConfigBase) { + function parse(config: Command.Command.Config) { for (const key in config) { tree[key] = parseValue(config[key]) } @@ -52,8 +52,8 @@ const parseConfig = (config: Command.Command.ConfigBase): Command.Command.Parsed value: | Args.Args | Options.Options - | ReadonlyArray | Options.Options | Command.Command.ConfigBase> - | Command.Command.ConfigBase + | ReadonlyArray | Options.Options | Command.Command.Config> + | Command.Command.Config ): Command.Command.ParsedConfigNode { if (Array.isArray(value)) { return { @@ -175,7 +175,7 @@ export const fromDescriptor = dual< } ) -const makeDescriptor = ( +const makeDescriptor = ( name: string, config: Config ): Descriptor.Command>> => { @@ -195,7 +195,7 @@ export const make: { {} > - ( + ( name: Name, config: Config ): Command.Command< @@ -205,7 +205,7 @@ export const make: { Types.Simplify> > - ( + ( name: Name, config: Config, handler: (_: Types.Simplify>) => Effect.Effect @@ -217,7 +217,7 @@ export const make: { > } = ( name: string, - config: Command.Command.ConfigBase = {}, + config: Command.Command.Config = {}, handler?: (_: any) => Effect.Effect ) => fromDescriptor(makeDescriptor(name, config) as any, handler as any) as any