Summary
root <subcommand> --help renders the first registered subcommand's help instead of the one the user named. This affects every non-first subcommand, and nested cases (root parent child --help). Root-level root --help is correct.
Related to #448 / #576: those fixed the bare --help case (no subcommand named, which dived into the first subcommand) and the cmd --help payload leftover case. They did not cover an explicitly named non-first subcommand, so this is a distinct, still-open facet. Note that #576 is already included in the 0.8.1 release, so upgrading does not fix this.
Reproduction
val tool = Command("tool").subcommands(
Command("start", Options.text("port")),
Command("stop")
)
tool.parse(List("tool", "stop", "--help"), CliConfig.default)
- Expected: help for
stop.
- Actual: help for
start (e.g. tool start [--port <text>]).
Reproduced on current master, which is identical to the 0.8.1 release in this code path.
Root cause (Command.scala)
-
Command.Single.parse — the final-check exhaustiveSearch forces this command's own name:
if (args.contains("--help") || args.contains("-h")) parseBuiltInArgs(List(name, "--help"))
So a Single returns its own help whenever --help appears anywhere in args, even when args do not name this command.
-
Command.Subcommands.parse — the UserDefined branch parses the child with the original conf (finalCheckBuiltIn = true):
child.parse(leftover, conf)
instead of the childConf (finalCheckBuiltIn = false) it already defines and uses in the dedicated help/wizard paths.
Trace for tool stop --help:
- Root
Single("tool") parses, leaving ["stop", "--help"] → the UserDefined branch.
- The child is a left-leaning
OrElse(start, stop), so start.parse(["stop", "--help"], conf) runs first.
start's name does not match stop, so normal parsing fails — but finalCheckBuiltIn == true and args.contains("--help"), so exhaustiveSearch fires and returns start's help.
OrElse sees a success and short-circuits; stop is never tried.
Suggested fix
Reuse childConf in the UserDefined branch (consistent with the help/wizard paths in the same method):
case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty =>
child.parse(leftover, childConf) // was: conf
With finalCheckBuiltIn = false for the child OrElse, a non-matching alternative fails with CommandMismatch instead of returning its own help, so OrElse advances to the alternative whose name matches. Optionally also guard exhaustiveSearch so it only fires when the leading token in args names this command.
Test gap
The current subcommand-help test only checks git add --help, where add is the first subcommand — which hides this bug. A regression test should name a non-first subcommand, e.g. git clone --help must show clone's help and not the first subcommand's.
Workaround
config = CliConfig.default.copy(finalCheckBuiltIn = false)
Summary
root <subcommand> --helprenders the first registered subcommand's help instead of the one the user named. This affects every non-first subcommand, and nested cases (root parent child --help). Root-levelroot --helpis correct.Related to #448 / #576: those fixed the bare
--helpcase (no subcommand named, which dived into the first subcommand) and thecmd --help payloadleftover case. They did not cover an explicitly named non-first subcommand, so this is a distinct, still-open facet. Note that #576 is already included in the 0.8.1 release, so upgrading does not fix this.Reproduction
stop.start(e.g.tool start [--port <text>]).Reproduced on current
master, which is identical to the 0.8.1 release in this code path.Root cause (
Command.scala)Command.Single.parse— the final-checkexhaustiveSearchforces this command's own name:So a
Singlereturns its own help whenever--helpappears anywhere inargs, even whenargsdo not name this command.Command.Subcommands.parse— theUserDefinedbranch parses the child with the originalconf(finalCheckBuiltIn = true):instead of the
childConf(finalCheckBuiltIn = false) it already defines and uses in the dedicated help/wizard paths.Trace for
tool stop --help:Single("tool")parses, leaving["stop", "--help"]→ theUserDefinedbranch.OrElse(start, stop), sostart.parse(["stop", "--help"], conf)runs first.start's name does not matchstop, so normal parsing fails — butfinalCheckBuiltIn == trueandargs.contains("--help"), soexhaustiveSearchfires and returnsstart's help.OrElsesees a success and short-circuits;stopis never tried.Suggested fix
Reuse
childConfin theUserDefinedbranch (consistent with the help/wizard paths in the same method):With
finalCheckBuiltIn = falsefor the childOrElse, a non-matching alternative fails withCommandMismatchinstead of returning its own help, soOrElseadvances to the alternative whose name matches. Optionally also guardexhaustiveSearchso it only fires when the leading token inargsnames this command.Test gap
The current subcommand-help test only checks
git add --help, whereaddis the first subcommand — which hides this bug. A regression test should name a non-first subcommand, e.g.git clone --helpmust showclone's help and not the first subcommand's.Workaround