diff --git a/packages/cli/README.md b/packages/cli/README.md index 2d138749cc2..c448581ad9c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -448,17 +448,73 @@ npx tsx echo.ts -b "This is a test" Both commands will display "This is a test" in bold text, assuming your terminal supports ANSI escape codes. -## Important Note on Argument Order +## Note on Argument Order -When using your CLI, it's crucial to understand the order in which you specify options and arguments. By default, the `@effect/cli` parses `Options` and `Args` **before** any subcommands. This means that options need to be placed directly after the main command, and before any subcommands or additional arguments. - -For example, the command: +Options can appear before or after positional arguments. Both of the following are valid: ```sh +npx tsx echo.ts -b "This is a test" npx tsx echo.ts "This is a test" -b ``` -**would not work** because the `-b` option appears after the text argument `"This is a test"`. The parser expects options to be specified before any standalone arguments or subcommands. This ensures that the options are correctly associated with the main command and not misinterpreted as arguments for a subcommand or additional text. +This flexibility also applies to parent command options when using subcommands: + +```ts +import { Args, Command, Options } from "@effect/cli" +import { NodeContext, NodeRuntime } from "@effect/platform-node" +import { Console, Effect } from "effect" + +const app = Command.make("app", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) +}) + +const deploy = Command.make("deploy", { + target: Args.text({ name: "target" }) +}, ({ target }) => + Effect.flatMap(app, ({ verbose }) => + Console.log(`Deploying ${target}${verbose ? " (verbose)" : ""}`) + ) +) + +const command = app.pipe(Command.withSubcommands([deploy])) +const cli = Command.run(command, { name: "app", version: "1.0.0" }) +cli(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain) +``` + +```sh +# All equivalent — parent options can appear anywhere: +npx tsx app.ts --verbose deploy prod +npx tsx app.ts deploy prod --verbose +npx tsx app.ts deploy --verbose prod +``` + +**Shared options:** When a parent and subcommand define the same option, position determines ownership — before the subcommand it belongs to the parent, after it belongs to the child: + +```ts +// Parent "app" has --verbose, child "status" also has --verbose +const status = Command.make("status", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")), + path: Args.text({ name: "path" }) +}, ({ verbose, path }) => + Effect.flatMap(app, (parent) => + Console.log( + `Status ${path}: child verbose=${verbose}, parent verbose=${parent.verbose}` + ) + ) +) +``` + +```sh +# --verbose before subcommand → parent gets it +npx tsx app.ts --verbose status . +# Output: Status .: child verbose=false, parent verbose=true + +# --verbose after subcommand → child gets it +npx tsx app.ts status . --verbose +# Output: Status .: child verbose=true, parent verbose=false +``` + +> **Note:** This positional flexibility applies only to **options** (flags like `--verbose` or `-v`), not to **arguments** (positional values). Arguments are always parsed in order. ## Adding Valued Options to Commands @@ -1196,45 +1252,39 @@ npx tsx minigit.ts -c key1=value1 clone --depth 1 https://github.com/Effect-TS/c Understanding how command-line arguments are parsed in your applications is crucial for designing effective and user-friendly command interfaces. Here are the key rules that the internal command-line argument parser follows: -### 1. Order of Options and Subcommands +### 1. Flexible Option Placement -Options and arguments (collectively referred to as `Options` / `Args`) associated with a command must be specified **before** any subcommands. This rule helps the parser determine which command the options apply to. +Options can appear before or after positional arguments and subcommands. The parser scans all arguments to find matching options regardless of position. **Examples:** -- **Correct Usage**: - - ```sh - program -v subcommand - ``` - - In this example, the `-v` option applies to the main program before the subcommand is processed. - -- **Incorrect Usage**: - ```sh - program subcommand -v - ``` - Here, placing `-v` after the subcommand causes confusion as to whether `-v` applies to the main program or the subcommand. +```sh +# All of these are equivalent: +program -v subcommand +program subcommand -v +``` -### 2. Parsing Options Before Positional Arguments +When a parent command and subcommand share the same option name, position determines ownership: -The parser is designed to recognize options before any positional arguments. This ordering ensures clarity and prevents confusion between options and regular arguments. +```sh +# -v belongs to the parent (before subcommand) +program -v subcommand -**Examples:** +# -v belongs to the subcommand (after subcommand) +program subcommand -v +``` -- **Valid Command**: +If only the parent defines an option, it is extracted regardless of position: - ```sh - program --option arg - ``` +```sh +# --config belongs to the parent in both cases +program --config prod.json subcommand arg +program subcommand arg --config prod.json +``` - This command correctly places the `--option` before the positional argument `arg`. +### 2. Positional Arguments Are Order-Dependent -- **Invalid Command**: - ```sh - program arg --option - ``` - Placing an argument before an option is not allowed and can lead to errors in command processing. +Unlike options, positional arguments are always parsed in the order they appear. They cannot be rearranged. ### 3. Handling Excess Arguments diff --git a/packages/cli/test/Command.test.ts b/packages/cli/test/Command.test.ts index 8466fc3c635..556a42550df 100644 --- a/packages/cli/test/Command.test.ts +++ b/packages/cli/test/Command.test.ts @@ -54,8 +54,25 @@ const add = Command.make("add", { Command.provideEffect(AddService, (_) => Effect.succeed("AddService" as const)) ) +const status = Command.make("status", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")), + pathspec: Args.text({ name: "pathspec" }) +}, ({ verbose, pathspec }) => + Effect.gen(function*() { + const { log } = yield* Messages + const parent = yield* git + if (verbose) { + yield* log(`Status verbose ${pathspec}`) + } else { + yield* log(`Status ${pathspec}`) + } + if (parent.verbose) { + yield* log("parent verbose") + } + })).pipe(Command.withDescription("Show the working tree status")) + const run = git.pipe( - Command.withSubcommands([clone, add]), + Command.withSubcommands([clone, add, status]), Command.run({ name: "git", version: "1.0.0" @@ -130,10 +147,9 @@ describe("Command", () => { Effect.runPromise )) - it("options after positional args", () => + it("parent options after subcommand and its args", () => Effect.gen(function*() { const messages = yield* Messages - // --verbose after the positional arg "repo" yield* run(["node", "git.js", "clone", "repo", "--verbose"]) assert.deepStrictEqual(yield* messages.messages, [ "shared", @@ -141,19 +157,120 @@ describe("Command", () => { ]) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) - it("options after positional args with alias", () => + it("parent options with alias after subcommand and its args", () => + Effect.gen(function*() { + const messages = yield* Messages + yield* run(["node", "git.js", "add", "file", "-v"]) + assert.deepStrictEqual(yield* messages.messages, [ + "shared", + "Adding file" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("parent options both before and after subcommand", () => Effect.gen(function*() { const messages = yield* Messages - // -v after the positional arg "repo" - yield* run(["node", "git.js", "clone", "repo", "-v"]) + // Mix: some parent options before subcommand, parent option after subcommand + // Using --verbose before and testing it still works + yield* run(["node", "git.js", "--verbose", "clone", "repo"]) assert.deepStrictEqual(yield* messages.messages, [ "shared", "Cloning repo" ]) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("shared option: child wins over parent", () => + Effect.gen(function*() { + const messages = yield* Messages + yield* run(["node", "git.js", "status", ".", "--verbose"]) + assert.deepStrictEqual(yield* messages.messages, [ + "shared", + "Status verbose ." + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) }) }) +// Parent provides shared options as a service to subcommands + +const SharedOptions = Context.GenericTag<{ verbose: boolean }>("SharedOptions") + +const app = Command.make("app", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) +}).pipe( + Command.provideSync(SharedOptions, ({ verbose }) => ({ verbose })) +) + +const push = Command.make("push", { + force: Options.boolean("force") +}, ({ force }) => + Effect.gen(function*() { + const { log } = yield* Messages + const { verbose } = yield* SharedOptions + yield* log(`push force=${force} verbose=${verbose}`) + })) + +const show = Command.make("show", { + ref: Args.text({ name: "ref" }) +}, ({ ref }) => + Effect.gen(function*() { + const { log } = yield* Messages + const { verbose } = yield* SharedOptions + yield* log(`show ${ref} verbose=${verbose}`) + })) + +const runApp = app.pipe( + Command.withSubcommands([push, show]), + Command.run({ name: "app", version: "1.0.0" }) +) + +describe("shared parent options provided as service", () => { + it("parent --verbose before subcommand", () => + Effect.gen(function*() { + const messages = yield* Messages + yield* runApp(["node", "app", "--verbose", "push", "--force"]) + assert.deepStrictEqual(yield* messages.messages, [ + "push force=true verbose=true" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("parent --verbose after subcommand options", () => + Effect.gen(function*() { + const messages = yield* Messages + yield* runApp(["node", "app", "push", "--force", "--verbose"]) + assert.deepStrictEqual(yield* messages.messages, [ + "push force=true verbose=true" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("parent -v after subcommand options", () => + Effect.gen(function*() { + const messages = yield* Messages + yield* runApp(["node", "app", "push", "--force", "-v"]) + assert.deepStrictEqual(yield* messages.messages, [ + "push force=true verbose=true" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("parent --verbose after subcommand positional arg", () => + Effect.gen(function*() { + const messages = yield* Messages + yield* runApp(["node", "app", "show", "main", "--verbose"]) + assert.deepStrictEqual(yield* messages.messages, [ + "show main verbose=true" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("no --verbose defaults to false", () => + Effect.gen(function*() { + const messages = yield* Messages + yield* runApp(["node", "app", "push", "--force"]) + assert.deepStrictEqual(yield* messages.messages, [ + "push force=true verbose=false" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) +}) + // -- interface Messages { diff --git a/packages/cli/test/CommandDescriptor.test.ts b/packages/cli/test/CommandDescriptor.test.ts index aee89463183..0ae0c7b9820 100644 --- a/packages/cli/test/CommandDescriptor.test.ts +++ b/packages/cli/test/CommandDescriptor.test.ts @@ -461,4 +461,232 @@ describe("Command", () => { yield* Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/fish-completions")) }).pipe(runEffect)) }) + + describe("parent options after subcommand arguments", () => { + // Fixture A: parent with text option --config/-c, subcommand "run" with text arg + const fixtureA = Descriptor.make("app", Options.text("config").pipe( + Options.withAlias("c"), + Options.withDefault("default.json") + )).pipe(Descriptor.withSubcommands([ + ["run", Descriptor.make("run", Options.none, Args.text())] + ])) + + // Fixture B: parent with boolean --verbose/-v + boolean --all/-a, + // subcommand "exec" with text arg and its own --verbose boolean + const fixtureB = Descriptor.make("tool", Options.all([ + Options.boolean("verbose").pipe(Options.withAlias("v")), + Options.boolean("all").pipe(Options.withAlias("a")) + ])).pipe(Descriptor.withSubcommands([ + ["exec", Descriptor.make( + "exec", + Options.boolean("verbose").pipe(Options.withAlias("v")), + Args.text() + )] + ])) + + // Fixture C: parent with integer --count/-n + boolean --debug/-d, + // subcommand "deploy" with text arg + text option --target/-t + const fixtureC = Descriptor.make("cli", Options.all([ + Options.integer("count").pipe(Options.withAlias("n"), Options.withDefault(0)), + Options.boolean("debug").pipe(Options.withAlias("d")) + ])).pipe(Descriptor.withSubcommands([ + ["deploy", Descriptor.make( + "deploy", + Options.text("target").pipe(Options.withAlias("t"), Options.withDefault("default")), + Args.text() + )] + ])) + + it("--config separated after child arg", () => + Effect.gen(function*() { + const args = Array.make("app", "run", "script.js", "--config", "prod.json") + const result = yield* Descriptor.parse(fixtureA, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "app", + options: "prod.json", + args: void 0, + subcommand: Option.some(["run", { name: "run", options: void 0, args: "script.js" }]) + })) + }).pipe(runEffect)) + + it("--config=value after child arg", () => + Effect.gen(function*() { + const args = Array.make("app", "run", "script.js", "--config=prod.json") + const result = yield* Descriptor.parse(fixtureA, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "app", + options: "prod.json", + args: void 0, + subcommand: Option.some(["run", { name: "run", options: void 0, args: "script.js" }]) + })) + }).pipe(runEffect)) + + it("-c short alias after child arg", () => + Effect.gen(function*() { + const args = Array.make("app", "run", "script.js", "-c", "prod.json") + const result = yield* Descriptor.parse(fixtureA, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "app", + options: "prod.json", + args: void 0, + subcommand: Option.some(["run", { name: "run", options: void 0, args: "script.js" }]) + })) + }).pipe(runEffect)) + + it("--config before subcommand (existing behavior)", () => + Effect.gen(function*() { + const args = Array.make("app", "--config", "prod.json", "run", "script.js") + const result = yield* Descriptor.parse(fixtureA, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "app", + options: "prod.json", + args: void 0, + subcommand: Option.some(["run", { name: "run", options: void 0, args: "script.js" }]) + })) + }).pipe(runEffect)) + + it("positional values not extracted as parent opts", () => + Effect.gen(function*() { + const args = Array.make("app", "run", "prod.json") + const result = yield* Descriptor.parse(fixtureA, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "app", + options: "default.json", + args: void 0, + subcommand: Option.some(["run", { name: "run", options: void 0, args: "prod.json" }]) + })) + }).pipe(runEffect)) + + it("shared --verbose after child arg: child wins", () => + Effect.gen(function*() { + const args = Array.make("tool", "exec", "task", "--verbose") + const result = yield* Descriptor.parse(fixtureB, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "tool", + options: [false, false], + args: void 0, + subcommand: Option.some(["exec", { name: "exec", options: true, args: "task" }]) + })) + }).pipe(runEffect)) + + it("shared -v after child arg: child wins", () => + Effect.gen(function*() { + const args = Array.make("tool", "exec", "task", "-v") + const result = yield* Descriptor.parse(fixtureB, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "tool", + options: [false, false], + args: void 0, + subcommand: Option.some(["exec", { name: "exec", options: true, args: "task" }]) + })) + }).pipe(runEffect)) + + it("shared option before subcommand: parent wins", () => + Effect.gen(function*() { + const args = Array.make("tool", "--verbose", "exec", "task") + const result = yield* Descriptor.parse(fixtureB, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "tool", + options: [true, false], + args: void 0, + subcommand: Option.some(["exec", { name: "exec", options: false, args: "task" }]) + })) + }).pipe(runEffect)) + + it("parent-only --all extracted, shared --verbose stays with child", () => + Effect.gen(function*() { + const args = Array.make("tool", "exec", "task", "--all", "--verbose") + const result = yield* Descriptor.parse(fixtureB, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "tool", + options: [false, true], + args: void 0, + subcommand: Option.some(["exec", { name: "exec", options: true, args: "task" }]) + })) + }).pipe(runEffect)) + + it("mixed: parent bool + parent int after child arg", () => + Effect.gen(function*() { + const args = Array.make("cli", "deploy", "prod", "--debug", "--count", "3") + const result = yield* Descriptor.parse(fixtureC, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "cli", + options: [3, true], + args: void 0, + subcommand: Option.some(["deploy", { name: "deploy", options: "default", args: "prod" }]) + })) + }).pipe(runEffect)) + + it("parent int with = syntax after child", () => + Effect.gen(function*() { + const args = Array.make("cli", "deploy", "prod", "--count=5") + const result = yield* Descriptor.parse(fixtureC, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "cli", + options: [5, false], + args: void 0, + subcommand: Option.some(["deploy", { name: "deploy", options: "default", args: "prod" }]) + })) + }).pipe(runEffect)) + + it("parent short -n after child arg", () => + Effect.gen(function*() { + const args = Array.make("cli", "deploy", "prod", "-n", "5") + const result = yield* Descriptor.parse(fixtureC, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "cli", + options: [5, false], + args: void 0, + subcommand: Option.some(["deploy", { name: "deploy", options: "default", args: "prod" }]) + })) + }).pipe(runEffect)) + + it("parent short -d bool after child arg", () => + Effect.gen(function*() { + const args = Array.make("cli", "deploy", "prod", "-d") + const result = yield* Descriptor.parse(fixtureC, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "cli", + options: [0, true], + args: void 0, + subcommand: Option.some(["deploy", { name: "deploy", options: "default", args: "prod" }]) + })) + }).pipe(runEffect)) + + it("child option stays with child", () => + Effect.gen(function*() { + const args = Array.make("cli", "deploy", "prod", "--target", "staging") + const result = yield* Descriptor.parse(fixtureC, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "cli", + options: [0, false], + args: void 0, + subcommand: Option.some(["deploy", { name: "deploy", options: "staging", args: "prod" }]) + })) + }).pipe(runEffect)) + + it("mixed parent + child options after arg", () => + Effect.gen(function*() { + const args = Array.make("cli", "deploy", "prod", "--debug", "--target", "staging", "--count", "2") + const result = yield* Descriptor.parse(fixtureC, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "cli", + options: [2, true], + args: void 0, + subcommand: Option.some(["deploy", { name: "deploy", options: "staging", args: "prod" }]) + })) + }).pipe(runEffect)) + + it("bool parent option does not steal next positional", () => + Effect.gen(function*() { + const args = Array.make("cli", "deploy", "prod", "--debug", "--count", "3") + const result = yield* Descriptor.parse(fixtureC, args, CliConfig.defaultConfig) + expect(result).toEqual(CommandDirective.userDefined(Array.empty(), { + name: "cli", + options: [3, true], + args: void 0, + subcommand: Option.some(["deploy", { name: "deploy", options: "default", args: "prod" }]) + })) + }).pipe(runEffect)) + }) })