Skip to content

Commit ad5704b

Browse files
cevrclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 0d1a44f commit ad5704b

File tree

2 files changed

+157
-1
lines changed

2 files changed

+157
-1
lines changed

packages/cli/src/internal/commandDescriptor.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,10 +669,27 @@ const parseInternal = (
669669
case "Subcommands": {
670670
const names = getNamesInternal(self)
671671
const subcommands = getSubcommandsInternal(self)
672-
const [parentArgs, childArgs] = Arr.span(
672+
const subcommandNames = Arr.map(subcommands, ([name]) => name)
673+
674+
// Get parent option names so we can extract them from anywhere in args
675+
const parentOptionNames = getParentOptionNames(self.parent)
676+
677+
// Extract parent options that appear after the subcommand
678+
const { parentArgs: extractedParentArgs, remainingArgs } = extractParentOptionsFromArgs(
673679
args,
680+
parentOptionNames,
681+
subcommandNames
682+
)
683+
684+
// Split remaining args at subcommand boundary (original logic)
685+
const [preSubcommandArgs, childArgs] = Arr.span(
686+
remainingArgs,
674687
(arg) => !Arr.some(subcommands, ([name]) => name === arg)
675688
)
689+
690+
// Combine args before subcommand with extracted parent options
691+
const parentArgs = Arr.appendAll(preSubcommandArgs, extractedParentArgs)
692+
676693
const parseChildrenWith = (argsForChildren: ReadonlyArray<string>) =>
677694
Effect.suspend(() => {
678695
const iterator = self.children[Symbol.iterator]()
@@ -828,6 +845,111 @@ const splitForcedArgs = (
828845
return [remainingArgs, Arr.drop(forcedArgs, 1)]
829846
}
830847

848+
/**
849+
* Get all option names (including aliases) from a command's options.
850+
* Traverses through Map and Subcommands wrappers to find Standard commands.
851+
*/
852+
const getParentOptionNames = (self: Instruction): Set<string> => {
853+
const names = new Set<string>()
854+
const collectFromCommand = (cmd: Instruction): void => {
855+
switch (cmd._tag) {
856+
case "Standard": {
857+
const optionNames = InternalOptions.getNames(cmd.options as InternalOptions.Instruction)
858+
for (const name of optionNames) {
859+
names.add(name)
860+
}
861+
break
862+
}
863+
case "Map": {
864+
collectFromCommand(cmd.command)
865+
break
866+
}
867+
case "Subcommands": {
868+
collectFromCommand(cmd.parent)
869+
break
870+
}
871+
case "GetUserInput": {
872+
break
873+
}
874+
}
875+
}
876+
collectFromCommand(self)
877+
return names
878+
}
879+
880+
/**
881+
* Extract parent options from anywhere in the args array, specifically
882+
* those appearing after a subcommand name. This enables the common CLI
883+
* pattern where global/parent options can appear at the end of the command.
884+
*
885+
* For example: `cli subcommand arg --parent-option value`
886+
*
887+
* Returns { parentArgs: extracted parent options, remainingArgs: everything else }
888+
*/
889+
const extractParentOptionsFromArgs = (
890+
args: ReadonlyArray<string>,
891+
parentOptionNames: Set<string>,
892+
subcommandNames: ReadonlyArray<string>
893+
): { parentArgs: Array<string>; remainingArgs: Array<string> } => {
894+
const parentArgs: Array<string> = []
895+
const remainingArgs: Array<string> = []
896+
897+
let i = 0
898+
let foundSubcommand = false
899+
900+
while (i < args.length) {
901+
const arg = args[i]
902+
903+
// Once we hit a subcommand, check subsequent args for parent options
904+
if (!foundSubcommand && subcommandNames.includes(arg)) {
905+
foundSubcommand = true
906+
remainingArgs.push(arg)
907+
i++
908+
continue
909+
}
910+
911+
// Only extract parent options AFTER the subcommand has been found
912+
// Before the subcommand, args go to remaining (original behavior)
913+
if (!foundSubcommand) {
914+
remainingArgs.push(arg)
915+
i++
916+
continue
917+
}
918+
919+
// Check if this is a parent option (starts with - and matches parent option names)
920+
if (arg.startsWith("-")) {
921+
// Handle --option=value format
922+
const equalsIndex = arg.indexOf("=")
923+
const optionName = equalsIndex !== -1 ? arg.substring(0, equalsIndex) : arg
924+
925+
if (parentOptionNames.has(optionName)) {
926+
// This is a parent option - extract it
927+
if (equalsIndex !== -1) {
928+
// --option=value format - single arg contains both
929+
parentArgs.push(arg)
930+
i++
931+
} else {
932+
// Check if next arg is the value (not another option, not a subcommand)
933+
parentArgs.push(arg)
934+
i++
935+
if (i < args.length && !args[i].startsWith("-") && !subcommandNames.includes(args[i])) {
936+
// Next arg is the value for this option
937+
parentArgs.push(args[i])
938+
i++
939+
}
940+
}
941+
continue
942+
}
943+
}
944+
945+
// Not a parent option - keep in remaining
946+
remainingArgs.push(arg)
947+
i++
948+
}
949+
950+
return { parentArgs, remainingArgs }
951+
}
952+
831953
const withDescriptionInternal = (
832954
self: Instruction,
833955
description: string | HelpDoc.HelpDoc

packages/cli/test/Command.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,40 @@ describe("Command", () => {
151151
"Cloning repo"
152152
])
153153
}).pipe(Effect.provide(EnvLive), Effect.runPromise))
154+
155+
it("parent options after subcommand and its args", () =>
156+
Effect.gen(function*() {
157+
const messages = yield* Messages
158+
// --verbose (parent option) appears after "clone repo" (subcommand + args)
159+
yield* run(["node", "git.js", "clone", "repo", "--verbose"])
160+
assert.deepStrictEqual(yield* messages.messages, [
161+
"shared",
162+
"Cloning repo"
163+
])
164+
}).pipe(Effect.provide(EnvLive), Effect.runPromise))
165+
166+
it("parent options with alias after subcommand and its args", () =>
167+
Effect.gen(function*() {
168+
const messages = yield* Messages
169+
// -v (parent option alias) appears after "add file" (subcommand + args)
170+
yield* run(["node", "git.js", "add", "file", "-v"])
171+
assert.deepStrictEqual(yield* messages.messages, [
172+
"shared",
173+
"Adding file"
174+
])
175+
}).pipe(Effect.provide(EnvLive), Effect.runPromise))
176+
177+
it("parent options both before and after subcommand", () =>
178+
Effect.gen(function*() {
179+
const messages = yield* Messages
180+
// Mix: some parent options before subcommand, parent option after subcommand
181+
// Using --verbose before and testing it still works
182+
yield* run(["node", "git.js", "--verbose", "clone", "repo"])
183+
assert.deepStrictEqual(yield* messages.messages, [
184+
"shared",
185+
"Cloning repo"
186+
])
187+
}).pipe(Effect.provide(EnvLive), Effect.runPromise))
154188
})
155189
})
156190

0 commit comments

Comments
 (0)