From c8c5f432e4eed14e4bad55467eee093c5106f398 Mon Sep 17 00:00:00 2001 From: cevr Date: Sun, 25 Jan 2026 22:20:32 -0500 Subject: [PATCH 1/6] fix(cli): allow parent options after subcommand arguments Enables parent/global options to appear anywhere in the command line, including after subcommand names and their arguments. This follows the common CLI pattern used by git, npm, docker, and most modern CLI tools. Before: cli --global-opt subcommand arg # works cli subcommand arg --global-opt # fails: "unknown option" After: cli --global-opt subcommand arg # works cli subcommand arg --global-opt # works This is useful for the "centralized flags" pattern where global options like --verbose, --config, or --model are defined on the parent command and inherited by all subcommands. Users can now place these options at the end of the command for better ergonomics. Implementation: - Extract parent option names before splitting args at subcommand boundary - Scan args after subcommand for known parent options - Pass extracted parent options to parent command parsing Co-Authored-By: Claude Opus 4.5 --- .../cli/src/internal/commandDescriptor.ts | 93 ++++++++++++++++++- packages/cli/test/Command.test.ts | 34 +++++++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/internal/commandDescriptor.ts b/packages/cli/src/internal/commandDescriptor.ts index 5b8437800aa..02ebaf68e51 100644 --- a/packages/cli/src/internal/commandDescriptor.ts +++ b/packages/cli/src/internal/commandDescriptor.ts @@ -669,10 +669,19 @@ const parseInternal = ( case "Subcommands": { const names = getNamesInternal(self) const subcommands = getSubcommandsInternal(self) - const [parentArgs, childArgs] = Arr.span( + const subcommandNames = new Set(Arr.map(subcommands, ([name]) => name)) + const parentOptionNames = getParentOptionNames(self.parent) + const { parentArgs: extractedParentArgs, remainingArgs } = extractParentOptionsFromArgs( args, - (arg) => !Arr.some(subcommands, ([name]) => name === arg) + parentOptionNames, + subcommandNames ) + const [preSubcommandArgs, childArgs] = Arr.span( + remainingArgs, + (arg) => !subcommandNames.has(arg) + ) + const parentArgs = Arr.appendAll(preSubcommandArgs, extractedParentArgs) + const parseChildrenWith = (argsForChildren: ReadonlyArray) => Effect.suspend(() => { const iterator = self.children[Symbol.iterator]() @@ -828,6 +837,86 @@ const splitForcedArgs = ( return [remainingArgs, Arr.drop(forcedArgs, 1)] } +const getParentOptionNames = (self: Instruction): Set => { + const names = new Set() + const loop = (cmd: Instruction): void => { + switch (cmd._tag) { + case "Standard": { + for (const name of InternalOptions.getNames(cmd.options as InternalOptions.Instruction)) { + names.add(name) + } + break + } + case "Map": { + loop(cmd.command) + break + } + case "Subcommands": { + loop(cmd.parent) + break + } + case "GetUserInput": { + break + } + } + } + loop(self) + return names +} + +const extractParentOptionsFromArgs = ( + args: ReadonlyArray, + parentOptionNames: Set, + subcommandNames: Set +): { parentArgs: Array; remainingArgs: Array } => { + const parentArgs: Array = [] + const remainingArgs: Array = [] + let i = 0 + let foundSubcommand = false + + while (i < args.length) { + const arg = args[i] + + if (!foundSubcommand && subcommandNames.has(arg)) { + foundSubcommand = true + remainingArgs.push(arg) + i++ + continue + } + + if (!foundSubcommand) { + remainingArgs.push(arg) + i++ + continue + } + + if (arg.startsWith("-")) { + const equalsIndex = arg.indexOf("=") + const optionName = equalsIndex !== -1 ? arg.substring(0, equalsIndex) : arg + + if (parentOptionNames.has(optionName)) { + if (equalsIndex !== -1) { + parentArgs.push(arg) + i++ + } else { + parentArgs.push(arg) + i++ + if (i < args.length && !args[i].startsWith("-") && !subcommandNames.has(args[i])) { + parentArgs.push(args[i]) + i++ + } + } + continue + } + } + + remainingArgs.push(arg) + i++ + } + + return { parentArgs, remainingArgs } +} + const withDescriptionInternal = ( self: Instruction, description: string | HelpDoc.HelpDoc diff --git a/packages/cli/test/Command.test.ts b/packages/cli/test/Command.test.ts index 8466fc3c635..4288a60974f 100644 --- a/packages/cli/test/Command.test.ts +++ b/packages/cli/test/Command.test.ts @@ -151,6 +151,40 @@ describe("Command", () => { "Cloning repo" ]) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("parent options after subcommand and its args", () => + Effect.gen(function*() { + const messages = yield* Messages + // --verbose (parent option) appears after "clone repo" (subcommand + args) + yield* run(["node", "git.js", "clone", "repo", "--verbose"]) + assert.deepStrictEqual(yield* messages.messages, [ + "shared", + "Cloning repo" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("parent options with alias after subcommand and its args", () => + Effect.gen(function*() { + const messages = yield* Messages + // -v (parent option alias) appears after "add file" (subcommand + args) + 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 + // 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)) }) }) From ce6a9f7360eec8c38b80aafc66557e88699e4f34 Mon Sep 17 00:00:00 2001 From: cevr Date: Fri, 6 Feb 2026 13:04:57 -0500 Subject: [PATCH 2/6] fix(cli): handle shared options and boolean edge cases in parent option extraction Child wins for shared options after subcommand (matches CLI conventions). Boolean parent options no longer steal the next token as a value. Adds comprehensive descriptor-level and integration tests. Co-Authored-By: Claude Opus 4.6 --- .changeset/cli-parent-options-shared.md | 9 + .../cli/src/internal/commandDescriptor.ts | 71 +++++- packages/cli/test/Command.test.ts | 53 ++-- packages/cli/test/CommandDescriptor.test.ts | 228 ++++++++++++++++++ 4 files changed, 332 insertions(+), 29 deletions(-) create mode 100644 .changeset/cli-parent-options-shared.md diff --git a/.changeset/cli-parent-options-shared.md b/.changeset/cli-parent-options-shared.md new file mode 100644 index 00000000000..1e4fd645885 --- /dev/null +++ b/.changeset/cli-parent-options-shared.md @@ -0,0 +1,9 @@ +--- +"@effect/cli": patch +--- + +Fix shared options and edge cases for parent options after subcommand arguments + +When a parent command and subcommand define the same option (e.g., both have `--verbose`), the subcommand now wins for options appearing after the subcommand. This matches CLI conventions (e.g., `git status --verbose` uses status's verbose, not git's). + +Also fixes boolean parent options incorrectly consuming the next token as a value, and respects `--` as a separator during parent option extraction. diff --git a/packages/cli/src/internal/commandDescriptor.ts b/packages/cli/src/internal/commandDescriptor.ts index 02ebaf68e51..77939c3be00 100644 --- a/packages/cli/src/internal/commandDescriptor.ts +++ b/packages/cli/src/internal/commandDescriptor.ts @@ -31,6 +31,7 @@ import * as InternalCommandDirective from "./commandDirective.js" import * as InternalHelpDoc from "./helpDoc.js" import * as InternalSpan from "./helpDoc/span.js" import * as InternalOptions from "./options.js" +import * as InternalPrimitive from "./primitive.js" import * as InternalPrompt from "./prompt.js" import * as InternalSelectPrompt from "./prompt/select.js" import * as InternalUsage from "./usage.js" @@ -670,10 +671,13 @@ const parseInternal = ( const names = getNamesInternal(self) const subcommands = getSubcommandsInternal(self) const subcommandNames = new Set(Arr.map(subcommands, ([name]) => name)) - const parentOptionNames = getParentOptionNames(self.parent) + const parentOptionInfo = getParentOptionInfo(self.parent) + const childOptionNames = getChildOptionNames(subcommands) const { parentArgs: extractedParentArgs, remainingArgs } = extractParentOptionsFromArgs( args, - parentOptionNames, + parentOptionInfo.names, + parentOptionInfo.booleanNames, + childOptionNames, subcommandNames ) const [preSubcommandArgs, childArgs] = Arr.span( @@ -837,14 +841,18 @@ const splitForcedArgs = ( return [remainingArgs, Arr.drop(forcedArgs, 1)] } -const getParentOptionNames = (self: Instruction): Set => { +const getParentOptionInfo = ( + self: Instruction +): { names: Set; booleanNames: Set } => { const names = new Set() + const booleanNames = new Set() const loop = (cmd: Instruction): void => { switch (cmd._tag) { case "Standard": { for (const name of InternalOptions.getNames(cmd.options as InternalOptions.Instruction)) { names.add(name) } + collectBooleanNames(cmd.options as InternalOptions.Instruction, booleanNames) break } case "Map": { @@ -861,12 +869,62 @@ const getParentOptionNames = (self: Instruction): Set => { } } loop(self) + return { names, booleanNames } +} + +const collectBooleanNames = (self: InternalOptions.Instruction, out: Set): void => { + switch (self._tag) { + case "Empty": { + break + } + case "Single": { + if (InternalPrimitive.isBool(self.primitiveType)) { + out.add(self.fullName) + for (const alias of self.aliases) { + out.add(alias.length === 1 ? `-${alias}` : `--${alias}`) + } + } + break + } + case "KeyValueMap": + case "Variadic": { + collectBooleanNames(self.argumentOption as InternalOptions.Instruction, out) + break + } + case "Map": + case "WithDefault": + case "WithFallback": { + collectBooleanNames(self.options as InternalOptions.Instruction, out) + break + } + case "Both": + case "OrElse": { + collectBooleanNames(self.left as InternalOptions.Instruction, out) + collectBooleanNames(self.right as InternalOptions.Instruction, out) + break + } + } +} + +const getChildOptionNames = ( + subcommands: Array<[string, GetUserInput | Standard]> +): Set => { + const names = new Set() + for (const [, cmd] of subcommands) { + if (cmd._tag === "Standard") { + for (const name of InternalOptions.getNames(cmd.options as InternalOptions.Instruction)) { + names.add(name) + } + } + } return names } const extractParentOptionsFromArgs = ( args: ReadonlyArray, parentOptionNames: Set, + parentBooleanOptionNames: Set, + childOptionNames: Set, subcommandNames: Set ): { parentArgs: Array; remainingArgs: Array } => { const parentArgs: Array = [] @@ -894,10 +952,15 @@ const extractParentOptionsFromArgs = ( const equalsIndex = arg.indexOf("=") const optionName = equalsIndex !== -1 ? arg.substring(0, equalsIndex) : arg - if (parentOptionNames.has(optionName)) { + // Child wins for shared options + if (parentOptionNames.has(optionName) && !childOptionNames.has(optionName)) { if (equalsIndex !== -1) { parentArgs.push(arg) i++ + } else if (parentBooleanOptionNames.has(optionName)) { + // Boolean options don't consume next token + parentArgs.push(arg) + i++ } else { parentArgs.push(arg) i++ diff --git a/packages/cli/test/Command.test.ts b/packages/cli/test/Command.test.ts index 4288a60974f..db1880065f3 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,32 +147,9 @@ describe("Command", () => { Effect.runPromise )) - it("options after positional 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", - "Cloning repo" - ]) - }).pipe(Effect.provide(EnvLive), Effect.runPromise)) - - it("options after positional args with alias", () => - Effect.gen(function*() { - const messages = yield* Messages - // -v after the positional arg "repo" - yield* run(["node", "git.js", "clone", "repo", "-v"]) - assert.deepStrictEqual(yield* messages.messages, [ - "shared", - "Cloning repo" - ]) - }).pipe(Effect.provide(EnvLive), Effect.runPromise)) - it("parent options after subcommand and its args", () => Effect.gen(function*() { const messages = yield* Messages - // --verbose (parent option) appears after "clone repo" (subcommand + args) yield* run(["node", "git.js", "clone", "repo", "--verbose"]) assert.deepStrictEqual(yield* messages.messages, [ "shared", @@ -166,7 +160,6 @@ describe("Command", () => { it("parent options with alias after subcommand and its args", () => Effect.gen(function*() { const messages = yield* Messages - // -v (parent option alias) appears after "add file" (subcommand + args) yield* run(["node", "git.js", "add", "file", "-v"]) assert.deepStrictEqual(yield* messages.messages, [ "shared", @@ -185,6 +178,16 @@ describe("Command", () => { "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)) }) }) 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)) + }) }) From 9d14d2831d833ef3249f199a8f44ccccbbea19fa Mon Sep 17 00:00:00 2001 From: cevr Date: Fri, 6 Feb 2026 13:05:52 -0500 Subject: [PATCH 3/6] docs(cli): update README for flexible option placement and shared options The FAQ and usage sections incorrectly stated options must appear before positional args and subcommands. Updated to reflect the new behavior and document shared option resolution (child wins after subcommand). Co-Authored-By: Claude Opus 4.6 --- packages/cli/README.md | 74 +++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 2d138749cc2..377cf4d0698 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -448,17 +448,31 @@ 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. For example, if a parent command defines `--verbose`, all of the following work: + +```sh +myapp --verbose clone repo +myapp clone repo --verbose +myapp clone --verbose repo +``` + +**Shared options:** When a parent command and a subcommand define the same option (e.g., both have `--verbose`), the option is resolved based on position: + +- **Before the subcommand:** it belongs to the parent +- **After the subcommand:** it belongs to the child + +This matches the behavior of tools like `git`, where `git --verbose status` uses git's verbose flag, while `git status --verbose` uses the status subcommand's verbose flag. + +> **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 +1210,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 From d2c19aee5da92a50cc8f82e6b42dd8e435879499 Mon Sep 17 00:00:00 2001 From: cevr Date: Fri, 6 Feb 2026 17:50:43 -0500 Subject: [PATCH 4/6] docs(cli): replace generic examples with concrete code in option order section Show actual @effect/cli code for flexible option placement and shared option resolution instead of referencing git behavior. Co-Authored-By: Claude Opus 4.6 --- packages/cli/README.md | 58 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 377cf4d0698..c448581ad9c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -457,20 +457,62 @@ npx tsx echo.ts -b "This is a test" npx tsx echo.ts "This is a test" -b ``` -This flexibility also applies to parent command options when using subcommands. For example, if a parent command defines `--verbose`, all of the following work: +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 -myapp --verbose clone repo -myapp clone repo --verbose -myapp clone --verbose repo +# 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 command and a subcommand define the same option (e.g., both have `--verbose`), the option is resolved based on position: +**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}` + ) + ) +) +``` -- **Before the subcommand:** it belongs to the parent -- **After the subcommand:** it belongs to the child +```sh +# --verbose before subcommand → parent gets it +npx tsx app.ts --verbose status . +# Output: Status .: child verbose=false, parent verbose=true -This matches the behavior of tools like `git`, where `git --verbose status` uses git's verbose flag, while `git status --verbose` uses the status subcommand's verbose flag. +# --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. From 46367af0d053440e69db418766f213c7816403d5 Mon Sep 17 00:00:00 2001 From: cevr Date: Fri, 6 Feb 2026 17:57:54 -0500 Subject: [PATCH 5/6] refactor(cli): remove redundant parent option extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extractParentOptionsFromArgs mechanism and all supporting helpers (getParentOptionInfo, collectBooleanNames, getChildOptionNames) are redundant — PR #5983's parseCommandLine scan + child leftover re-parse path already handles parent options after subcommand args, including shared option resolution and boolean awareness. 177/177 tests pass without any of this code. Co-Authored-By: Claude Opus 4.6 --- .changeset/cli-parent-options-shared.md | 9 - .../cli/src/internal/commandDescriptor.ts | 156 +----------------- 2 files changed, 2 insertions(+), 163 deletions(-) delete mode 100644 .changeset/cli-parent-options-shared.md diff --git a/.changeset/cli-parent-options-shared.md b/.changeset/cli-parent-options-shared.md deleted file mode 100644 index 1e4fd645885..00000000000 --- a/.changeset/cli-parent-options-shared.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@effect/cli": patch ---- - -Fix shared options and edge cases for parent options after subcommand arguments - -When a parent command and subcommand define the same option (e.g., both have `--verbose`), the subcommand now wins for options appearing after the subcommand. This matches CLI conventions (e.g., `git status --verbose` uses status's verbose, not git's). - -Also fixes boolean parent options incorrectly consuming the next token as a value, and respects `--` as a separator during parent option extraction. diff --git a/packages/cli/src/internal/commandDescriptor.ts b/packages/cli/src/internal/commandDescriptor.ts index 77939c3be00..5b8437800aa 100644 --- a/packages/cli/src/internal/commandDescriptor.ts +++ b/packages/cli/src/internal/commandDescriptor.ts @@ -31,7 +31,6 @@ import * as InternalCommandDirective from "./commandDirective.js" import * as InternalHelpDoc from "./helpDoc.js" import * as InternalSpan from "./helpDoc/span.js" import * as InternalOptions from "./options.js" -import * as InternalPrimitive from "./primitive.js" import * as InternalPrompt from "./prompt.js" import * as InternalSelectPrompt from "./prompt/select.js" import * as InternalUsage from "./usage.js" @@ -670,22 +669,10 @@ const parseInternal = ( case "Subcommands": { const names = getNamesInternal(self) const subcommands = getSubcommandsInternal(self) - const subcommandNames = new Set(Arr.map(subcommands, ([name]) => name)) - const parentOptionInfo = getParentOptionInfo(self.parent) - const childOptionNames = getChildOptionNames(subcommands) - const { parentArgs: extractedParentArgs, remainingArgs } = extractParentOptionsFromArgs( + const [parentArgs, childArgs] = Arr.span( args, - parentOptionInfo.names, - parentOptionInfo.booleanNames, - childOptionNames, - subcommandNames + (arg) => !Arr.some(subcommands, ([name]) => name === arg) ) - const [preSubcommandArgs, childArgs] = Arr.span( - remainingArgs, - (arg) => !subcommandNames.has(arg) - ) - const parentArgs = Arr.appendAll(preSubcommandArgs, extractedParentArgs) - const parseChildrenWith = (argsForChildren: ReadonlyArray) => Effect.suspend(() => { const iterator = self.children[Symbol.iterator]() @@ -841,145 +828,6 @@ const splitForcedArgs = ( return [remainingArgs, Arr.drop(forcedArgs, 1)] } -const getParentOptionInfo = ( - self: Instruction -): { names: Set; booleanNames: Set } => { - const names = new Set() - const booleanNames = new Set() - const loop = (cmd: Instruction): void => { - switch (cmd._tag) { - case "Standard": { - for (const name of InternalOptions.getNames(cmd.options as InternalOptions.Instruction)) { - names.add(name) - } - collectBooleanNames(cmd.options as InternalOptions.Instruction, booleanNames) - break - } - case "Map": { - loop(cmd.command) - break - } - case "Subcommands": { - loop(cmd.parent) - break - } - case "GetUserInput": { - break - } - } - } - loop(self) - return { names, booleanNames } -} - -const collectBooleanNames = (self: InternalOptions.Instruction, out: Set): void => { - switch (self._tag) { - case "Empty": { - break - } - case "Single": { - if (InternalPrimitive.isBool(self.primitiveType)) { - out.add(self.fullName) - for (const alias of self.aliases) { - out.add(alias.length === 1 ? `-${alias}` : `--${alias}`) - } - } - break - } - case "KeyValueMap": - case "Variadic": { - collectBooleanNames(self.argumentOption as InternalOptions.Instruction, out) - break - } - case "Map": - case "WithDefault": - case "WithFallback": { - collectBooleanNames(self.options as InternalOptions.Instruction, out) - break - } - case "Both": - case "OrElse": { - collectBooleanNames(self.left as InternalOptions.Instruction, out) - collectBooleanNames(self.right as InternalOptions.Instruction, out) - break - } - } -} - -const getChildOptionNames = ( - subcommands: Array<[string, GetUserInput | Standard]> -): Set => { - const names = new Set() - for (const [, cmd] of subcommands) { - if (cmd._tag === "Standard") { - for (const name of InternalOptions.getNames(cmd.options as InternalOptions.Instruction)) { - names.add(name) - } - } - } - return names -} - -const extractParentOptionsFromArgs = ( - args: ReadonlyArray, - parentOptionNames: Set, - parentBooleanOptionNames: Set, - childOptionNames: Set, - subcommandNames: Set -): { parentArgs: Array; remainingArgs: Array } => { - const parentArgs: Array = [] - const remainingArgs: Array = [] - let i = 0 - let foundSubcommand = false - - while (i < args.length) { - const arg = args[i] - - if (!foundSubcommand && subcommandNames.has(arg)) { - foundSubcommand = true - remainingArgs.push(arg) - i++ - continue - } - - if (!foundSubcommand) { - remainingArgs.push(arg) - i++ - continue - } - - if (arg.startsWith("-")) { - const equalsIndex = arg.indexOf("=") - const optionName = equalsIndex !== -1 ? arg.substring(0, equalsIndex) : arg - - // Child wins for shared options - if (parentOptionNames.has(optionName) && !childOptionNames.has(optionName)) { - if (equalsIndex !== -1) { - parentArgs.push(arg) - i++ - } else if (parentBooleanOptionNames.has(optionName)) { - // Boolean options don't consume next token - parentArgs.push(arg) - i++ - } else { - parentArgs.push(arg) - i++ - if (i < args.length && !args[i].startsWith("-") && !subcommandNames.has(args[i])) { - parentArgs.push(args[i]) - i++ - } - } - continue - } - } - - remainingArgs.push(arg) - i++ - } - - return { parentArgs, remainingArgs } -} - const withDescriptionInternal = ( self: Instruction, description: string | HelpDoc.HelpDoc From a6f117ebd49cf89504811db2b2c1fd66d6731dfb Mon Sep 17 00:00:00 2001 From: cevr Date: Fri, 6 Feb 2026 18:03:17 -0500 Subject: [PATCH 6/6] test(cli): add integration test for parent options provided as service Tests the pattern where a parent command provides shared options (like --verbose) as a service to subcommands. Verifies parent options work when placed after subcommand names and subcommand options. Co-Authored-By: Claude Opus 4.6 --- packages/cli/test/Command.test.ts | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/cli/test/Command.test.ts b/packages/cli/test/Command.test.ts index db1880065f3..556a42550df 100644 --- a/packages/cli/test/Command.test.ts +++ b/packages/cli/test/Command.test.ts @@ -191,6 +191,86 @@ describe("Command", () => { }) }) +// 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 {