Skip to content

Report a mistyped subcommand under a group command instead of exiting 0 - #41

Merged
178inaba merged 3 commits into
mainfrom
fix/27-group-unknown-subcommand
Aug 23, 2026
Merged

Report a mistyped subcommand under a group command instead of exiting 0#41
178inaba merged 3 commits into
mainfrom
fix/27-group-unknown-subcommand

Conversation

@178inaba

Copy link
Copy Markdown
Owner

Why

A mistyped subcommand under a group command reported success:

$ rdsh data-source lsit >/dev/null 2>&1; echo $?
0

rdsh data-source lsit wrote the data-source help to stdout, nothing to stderr,
and exited 0. A caller — a script, or an agent branching on the exit code — read that
as "the command worked", and found help text on the stream it parses for query results.
CLAUDE.md makes stdout content and the exit code a machine-consumed contract; a failed
invocation satisfied neither half. auth, data-source, profile, query and the
completion command cobra generates were all affected; only rdsh bogus at the root
already reported.

cobra only reports this for the root. Find calls legacyArgs only when the resolved
command has no Args, and legacyArgs returns unknown command … only for a root with
subcommands — under a group the command has a parent, so it returns nil. Execution
then reaches if !c.Runnable() { return flag.ErrHelp } in Command.execute, which sits
before ValidateArgs, and ExecuteC treats flag.ErrHelp as "print help, return
nil". Setting Args on the groups does not fix it — the Runnable check returns
first. cobra's own completion is the proof: it already has Args: NoArgs and still
exited 0.

What

Override the help function on the root — the one hook on that path — so a stray argument
to a non-runnable command becomes a recorded failure instead of a help render. One
override covers the whole tree, including the generated completion, because HelpFunc
walks to the parent when a command has none of its own. No field set in rdsh's own
constructors can reach that far.

$ rdsh data-source lsit; echo $?
Error: unknown command "lsit" for "rdsh data-source"

Did you mean this?
	list

1

The message is rebuilt to match legacyArgs and findSuggestions, both unexported, so
a typo reads the same at either depth. Two details that are easy to lose:

  • help is answered with --help. cobra registers a help command on the root alone,
    so under a group it is as stray as anything else, and SuggestionsFor only ever looks
    at registered subcommands — nothing would be offered without the branch. This changes
    rdsh auth help and friends from today's 0 to 1.
  • SuggestionsMinimumDistance is set to 2 before calling SuggestionsFor.
    findSuggestions sets that default itself; the exported SuggestionsFor reads the
    distance as it finds it. Left at zero, every Levenshtein candidate is lost — lsit
    stops suggesting list — while prefix matches keep working, so it fails quietly.

No usage listing follows the message, unlike gh's equivalent: the root sets
SilenceUsage precisely because usage on every failure is noise for the agent consumer,
and a root-level typo prints none either.

The second commit drops the Args/RunE guard rdsh query carried for this, whose
comment said it could go once this landed. query now also gains the suggestion list
cobra.NoArgs never produced.

Behaviour that does not change: rdsh, and each group alone or with --help, still
print help to stdout and exit 0; rdsh bogus still reports the same message; every
command still resolves and runs as before.

Both open questions, and a departure from the suggested approach

The issue left two things to the implementer and suggested a third. All three resolve
together, so they are one decision rather than three.

The suggested approach — ported from gh — has the help function write the message to
cmd.ErrOrStderr() itself and hand Execute a bare boolean. This takes the other
half of that split: the help function builds the error and records it, and Execute's
existing Error: line prints it.
All seven numbered requirements hold either way, so
this departs from the prose, not from the requirements. What it buys:

  • Requirement 2 holds by construction. A root-level typo and a group-level one now
    travel through the same fmt.Fprintln, so the prefix and the shape cannot drift apart.
    Worth naming because the sibling CLIs show both outcomes of the alternative: slio
    added a shared errorPrefix constant specifically to stop the two writers drifting,
    and cflio has two independent "Error:" literals with nothing tying them together.
  • The second open question disappears. exitCode still produces the 1 through its
    default arm, so the sentence in CLAUDE.md naming it as the producer of the contract's
    codes stays true and needs no amendment — with no sentinel, and no bare return 1
    bypassing it. Under the suggested shape this question has to be answered, which is why
    the issue asks it; slio threads a silent sentinel through its classifier and cflio
    returns 1 directly, two different answers to the same prompt.
  • The blast radius shrinks. Merging in runRdshInto with if err == nil { err = report.err }
    leaves every outer helper's signature untouched, so the ripple into auth_test.go the
    issue anticipated does not happen, and TestQueryGroupArguments keeps observing this
    failure the way it observes every other one — as a returned error.

The recorded failure lives in helpReport, a small struct returned alongside the command
from newRootCmd, rather than a package-level var — the reason globalFlags already
gives.

cflio and slio both landed the suggested shape. 178inaba/cflio#52 and
178inaba/slio#29 track bringing them to this one, so the three CLIs converge rather than
carrying three variants of one workaround.

Where the tests live, and why it is not where the issue expected

The issue predicted that a process exit code could not be observed and that the seam was
in query_test.go. Both have moved since it was filed:

  • The interrupt work landed internal/cmd/execute_test.go with startRdsh /
    assertExited, which read a real exit status and keep stdout and stderr apart. So the
    first five acceptance criteria go there directly — exit 1, exactly one Error: line
    on stderr, empty stdout — rather than being approximated in-process.
  • The seam is runRdshInto in run_test.go, not query_test.go, and runRdshSplit
    already separated the streams. TestQueryGroupArguments stays in-process and is what
    pins the helper's merge.

Every acceptance criterion was also checked against a built binary, including the cases
the tests cover, since the wiring from a recorded failure to a process exit is the part a
test seam cannot reach on its own.

Note on the exit code contract

<group> <typo> and <group> help go from 0 to 1. CLAUDE.md holds exit-code
changes to a higher bar than a human-facing CLI would, so: the meanings of 0 / 124 /
1 are unchanged, and this only stops a failed invocation from claiming the success
code. No documentation change goes with it — skills/rdsh/SKILL.md already says any
other failure exits 1, and README.md states only the 124 and its one 1 exception.

Closes #27

cobra answers an argument that is not one of a group command's
subcommands by printing that group's help to stdout and returning nil,
so a caller branching on the exit code reads a failed invocation as a
success and finds help text on the stream it parses for results. No Args
on a group can catch it: execute returns flag.ErrHelp for a command that
is not runnable before ValidateArgs is ever reached.

Override the root's help function, which is what cobra calls on that
path, and record the failure for Execute to print and map. One override
covers the whole tree, including the completion command cobra generates
during Execute, because HelpFunc walks to the parent.
The Args/RunE pair on the query group was a local stand-in for the hole
the root's help override now closes for every group. Removing it also
gains query the suggestion list cobra.NoArgs never produced.
The two tables spelled out a row per group for a branch they share, and
started an httptest.Server per row that nothing ever dialled. A loop over
groupCommands covers the same cases, and the three assertions every
rejected invocation shares move into assertReportedOnce.

Also assign SuggestionsMinimumDistance rather than defaulting it: nothing
else in rdsh sets the field, so the guard could never take its false
branch.
@178inaba 178inaba self-assigned this Aug 23, 2026
@178inaba
178inaba merged commit 3a26bbf into main Aug 23, 2026
2 checks passed
@178inaba
178inaba deleted the fix/27-group-unknown-subcommand branch August 23, 2026 16:44
@daemon-bot daemon-bot Bot mentioned this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Report a mistyped subcommand under a group command instead of exiting 0

1 participant