Skip to content

--help on a non-first subcommand shows the first subcommand's help instead #586

Description

@Sootopolis

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)

  1. 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.

  2. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions