Skip to content

fix: complete subcommands after a flag that needs no value on the root command - #2501

Merged
Juneezee merged 1 commit into
urfave:mainfrom
vidigoat:fix/complete-commands-after-finished-flag
Nov 7, 2026
Merged

Juneezee merged 1 commit into
urfave:mainfrom
vidigoat:fix/complete-commands-after-finished-flag

Conversation

@vidigoat

@vidigoat vidigoat commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • bug

What this PR does / why we need it:

The shell scripts send the word under the cursor only when it starts with -, so for app --verbose <TAB> the program gets app --verbose --generate-shell-completion. A subcommand completes its parsed arguments, where --verbose has already been consumed, so app deploy --verbose <TAB> lists deploy's subcommands. The root command completes its raw arguments instead, so its last argument is --verbose, DefaultCompleteWithFlags prints flag suggestions for it, and since printFlagSuggestions skips the flag that was typed in full, it prints nothing.

A short name hits the same bug. Since #2502, printFlagSuggestions prints nothing for a short flag written out in full, so with

&cli.BoolFlag{Name: "force", Aliases: []string{"f"}},

app -f <TAB> falls back to file names in bash and zsh, fish shows nothing, and app -f d<TAB> completes nothing.

Bash splits words at =, so on main app --config=x <TAB> already lists the subcommands in bash. Only zsh and fish showed nothing.

app --verbose <TAB>         # nothing (bash falls back to file names, zsh to _files, fish shows nothing)
app --verbose d<TAB>        # nothing
app --config=x <TAB>        # subcommands in bash; nothing in zsh and fish
app -f <TAB>                # nothing
app -f d<TAB>               # nothing
app -c x <TAB>              # build deploy help
app deploy --verbose <TAB>  # staging prod help

The program cannot tell app --debug<TAB> from app --debug <TAB>. It prints the flag suggestions, and when the last argument is a flag that takes no value (including a short alias and a BoolWithInverseFlag's --no- names) or has its value after =, it prints the subcommands too. The bash, zsh and fish scripts keep only the suggestions that match the word being typed:

  • --debug<TAB>, --verb<TAB> and -f<TAB> complete as they do now
  • --debug <TAB> lists the subcommands next to --debug-level, and -f <TAB> lists the subcommands
  • -f d<TAB> completes deploy
  • --format with no value still prints nothing, and a partial flag such as --verb still suggests only --verbose

Subcommands do not reach the new branch for a known flag, since their parsed arguments no longer hold it.

This is separate from #2489, which changes where the root command's arguments come from and not how the last one is read. It is rebased on #2489, so the new test passes its arguments to Run instead of setting os.Args.

Which issue(s) this PR fixes:

None, found while testing the generated scripts in bash and fish.

Testing

  • TestCompletionAfterCompleteRootFlag: a bool flag, an inverse bool flag and --format=json list the subcommands (these three fail on main); --debug lists --debug-level and the subcommand; the short alias -v lists the subcommand; --format with no value and a partial --verb keep their current output.
  • The bash, zsh and fish scripts keep only the suggestions that match the word being typed, so --debug<TAB>, --verb<TAB> and -f<TAB> complete as before. --debug <TAB> lists the subcommands next to --debug-level, -f <TAB> lists the subcommands, and -f d<TAB> completes deploy.
  • go test ./... passes.

The change and the test were written with Claude Code (AI).

Release Notes

Shell completion on the root command now lists subcommands after a flag that takes no value or has its value after "=".

@vidigoat
vidigoat requested a review from a team as a code owner October 25, 2026 12:10
@vidigoat

Copy link
Copy Markdown
Contributor Author

The ubuntu-24.04 @ Go stable failure is make check-binary-size (the Windows jobs were cancelled after it): the CLI's share of the example binary comes out at 2.3MB against the 2.2MB max.

The change adds about 1.2KB (704 bytes of text and 527 of pclntab, measured with Go 1.27.3 for linux/amd64), but main is already close to the limit: it reports 2.2MB, and the check rounds to one decimal, so anything that crosses the next 4KB page reports 2.3MB. A version without the buffer lands on the same page, so I don't think trimming the code gets it back under.

I can raise desiredMaxBinarySize (and desiredMinBinarySize by the same amount, as the script suggests) in this PR if you think the fix is worth it, or leave the limit to you.

@vidigoat

Copy link
Copy Markdown
Contributor Author

I've added a second commit that raises both limits by 0.1MB, as the check suggests, so CI can go green. The smallest size in CI (macos-15 @ Go oldstable, 1.6MB) stays above the new minimum. #2502 carries the same commit, so they merge in either order. Happy to drop it if you'd rather handle the limit separately.

Comment thread completion_test.go Outdated
Comment on lines +455 to +459
// The root command completes os.Args, which still hold the flags, so a
// flag that needs nothing more must lead to the subcommands rather than
// to an empty list of flag suggestions.
origArgv := os.Args
t.Cleanup(func() { os.Args = origArgv })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not required after #2489

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. On current main the root command still reads os.Args, so the test fails without these lines until #2489 lands. I checked the version without them on top of #2489 and it passes, so I'll drop them as soon as #2489 is merged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped them now that #2489 is merged: rebased on main, and the test passes its arguments to Run like the other completion tests.

@vidigoat
vidigoat force-pushed the fix/complete-commands-after-finished-flag branch from 2127145 to 245af54 Compare October 29, 2026 10:18
@vidigoat

vidigoat commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

@Juneezee @coilysiren friendly ping on #2501, #2502 and #2506. All three are rebased on main with the review points addressed, and CI is green on each. Let me know if anything else is needed.

@Juneezee Juneezee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the bug is real: on main, app --verbose <TAB> falls back to file names in bash and zsh, and fish shows nothing. The PR fixes it in all three.

A short name still hits it. Add this flag to the example app in the description:

&cli.BoolFlag{Name: "force", Aliases: []string{"f"}},

app -f <TAB> completes to app -f --force in bash and zsh, fish offers only --force, and app -f d<TAB> completes nothing. printFlagSuggestions matches f against the flag's own name, force:

cli/help.go

Lines 246 to 256 in 7dad5b6

name := strings.TrimSpace(flag.Names()[0])
// this will get total count utf8 letters in flag name
// reuse this count to generate single - or -- in flag completion
count := min(utf8.RuneCountInString(name), 2)
// if flag name has more than one utf8 letter and last argument in cli has -- prefix then
// skip flag completion for short flags example -v or -x
if strings.HasPrefix(lastArg, "--") && count == 1 {
continue
}
// match if last argument matches this flag and it is not repeated
if strings.HasPrefix(name, cur) && cur != name /* && !cliArgContains(name, os.Args)*/ {

so the suggestions are never empty, and the new check never treats -f as complete:

cli/help.go

Lines 310 to 317 in 245af54

// A flag that needs no value, or has its value after "=", is complete,
// so suggest what can follow it unless it starts a longer flag name.
var suggestions bytes.Buffer
printFlagSuggestions(lastArg, flags, &suggestions)
if suggestions.Len() > 0 || !isCompleteFlag(lastArg, flags) {
_, _ = cmd.Root().Writer.Write(suggestions.Bytes())
return
}

The program cannot tell app --debug<TAB> from app --debug <TAB>, but the shell can. How about printing both and letting the shell pick? It also drops the buffer and the bytes import:

		printFlagSuggestions(lastArg, flags, cmd.Root().Writer)
		// The last argument may be the flag being typed, as in "app --debug<TAB>",
		// or one before the cursor, as in "app --debug <TAB>". The bash, zsh and
		// fish scripts keep only the suggestions that match the word being typed,
		// so after a complete flag, suggest the subcommands too.
		if !isCompleteFlag(lastArg, flags) {
			return
		}

In the test, add Aliases: []string{"v"} to the verbose flag and change the cases to:

		{name: "start of a longer flag", arg: "--debug", want: []string{"--debug-level", "sub"}},
		{name: "short alias", arg: "-v", want: []string{"--verbose", "sub"}},

With these, --debug<TAB>, --verb<TAB> and -f<TAB> complete as they do now. --debug <TAB> and -f <TAB> list the subcommands next to --debug-level and --force, and -f d<TAB> completes deploy. I checked this in bash 5.3, zsh and fish 4.9.3. go test ./... (also with -race and the three build tags), golangci-lint and check-binary-size pass.

Please also update the description (the "no longer flag starts with it" rule and the --debug example) and the last sentence of the commit message.

One correction to the description: bash splits words at =, so on main app --config=x <TAB> already lists the subcommands in bash. Only zsh and fish showed nothing.

@vidigoat

vidigoat commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

@Juneezee Thank you. These are done.

DefaultCompleteWithFlags prints the flag suggestions, and when the last argument is a complete flag it prints the subcommands too. The buffer and the bytes import are gone. The test gives verbose the alias v: --debug suggests --debug-level and the subcommand, and -v suggests --verbose and the subcommand.

The description drops the "no longer flag starts with it" rule, covers the --debug and -f behavior, and corrects --config=x: bash already listed the subcommands, and only zsh and fish showed nothing.

@vidigoat
vidigoat force-pushed the fix/complete-commands-after-finished-flag branch from 6ace056 to 726b346 Compare November 7, 2026 15:04
The root command completes the arguments passed to Run, which still hold
its flags, so after a flag that takes no value or has its value after
"=", such as "app --verbose <TAB>", it printed flag suggestions for that
flag and found none. A subcommand completes its parsed arguments and
does not have this problem. Print the flag suggestions either way, and
when the flag needs nothing more print the subcommands too, so the shell
can keep the ones that match the word being typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Eng Zer Jun <engzerjun@gmail.com>
@Juneezee
Juneezee force-pushed the fix/complete-commands-after-finished-flag branch from 726b346 to 40f2e2a Compare November 7, 2026 15:34
@Juneezee

Juneezee commented Nov 7, 2026

Copy link
Copy Markdown
Member

Thanks! #2502 merged after CI ran here and changed one case: since #2502, -v gets no flag suggestions, so short alias now gets ["sub"]. I rebased this on main, changed that case to []string{"sub"}, rewrapped the commit message at 72 and updated the -f parts of the description to match.

@Juneezee
Juneezee merged commit d854bb4 into urfave:main Nov 7, 2026
9 checks passed
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.

2 participants