From 3f2ae26b409a99242811eaa9997c81490dc0a7ce Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 19 Aug 2026 14:09:20 +0200 Subject: [PATCH 1/2] Print full help on command misuse for invoking agents When an agent misuses a command, the terse usage string does not carry the examples, JSON fields or environment variables it needs to correct itself, forcing a second `--help` invocation. Extract the help renderer out of rootHelpFunc so it can target any writer, then use it from printError when an agent is detected. Rendering directly to stderr avoids cmd.Help(), which writes to stdout and would otherwise split a single failure across two streams. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/ghcmd/cmd.go | 16 +++++- internal/ghcmd/cmd_test.go | 101 +++++++++++++++++++++++++++++++++---- pkg/cmd/root/help.go | 18 +++++-- 3 files changed, 118 insertions(+), 17 deletions(-) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 8039de3fa58..ed4e1d0b574 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -223,7 +223,7 @@ func Main() exitCode { return exitCode(extError.ExitCode()) } - printError(stderr, err, cmd, hasDebug) + printError(stderr, ioStreams.ColorScheme(), err, cmd, hasDebug, invokingAgent != "") if strings.Contains(err.Error(), "Incorrect function") { fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.") @@ -279,7 +279,12 @@ func isExtensionCommand(rootCmd *cobra.Command, args []string) bool { return err == nil && c != nil && c.GroupID == "extension" } -func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { +// printError writes err to out, followed by usage information when the error +// is the result of command misuse. When fullHelp is set the complete help text +// is written instead of the terse usage string, giving AI agents the examples, +// JSON fields and environment variables they need to correct themselves without +// a second round trip. +func printError(out io.Writer, cs *iostreams.ColorScheme, err error, cmd *cobra.Command, debug, fullHelp bool) { var dnsError *net.DNSError if errors.As(err, &dnsError) { fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name) @@ -297,6 +302,13 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { if !strings.HasSuffix(err.Error(), "\n") { fmt.Fprintln(out) } + if fullHelp { + // Render into out rather than calling cmd.Help(), which would send + // the help text to stdout and split a single failure across two + // streams. + root.WriteHelp(out, cs, cmd) + return + } fmt.Fprintln(out, cmd.UsageString()) } } diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index f11f8ac4483..cdc50edae7c 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -9,12 +9,14 @@ import ( "os" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -22,12 +24,22 @@ import ( ) func Test_printError(t *testing.T) { - cmd := &cobra.Command{} + rootCmd := &cobra.Command{Use: "gh"} + cmd := &cobra.Command{ + Use: "spend", + Short: "Spend money", + Example: heredoc.Doc(` + $ gh spend --amount 1 + `), + } + cmd.Flags().Int("amount", 0, "How much to spend") + rootCmd.AddCommand(cmd) type args struct { - err error - cmd *cobra.Command - debug bool + err error + cmd *cobra.Command + debug bool + fullHelp bool } tests := []struct { name string @@ -63,7 +75,7 @@ check your internet connection or https://githubstatus.com cmd: cmd, debug: false, }, - wantOut: "unknown flag --foo\n\nUsage:\n\n", + wantOut: "unknown flag --foo\n\n" + cmd.UsageString() + "\n", }, { name: "unknown Cobra command error", @@ -72,17 +84,86 @@ check your internet connection or https://githubstatus.com cmd: cmd, debug: false, }, - wantOut: "unknown command foo\n\nUsage:\n\n", + wantOut: "unknown command foo\n\n" + cmd.UsageString() + "\n", + }, + { + name: "Cobra flag error with full help", + args: args{ + err: cmdutil.FlagErrorf("unknown flag --foo"), + cmd: cmd, + debug: false, + fullHelp: true, + }, + wantOut: heredoc.Doc(` + unknown flag --foo + + Spend money + + USAGE + gh spend [flags] + + FLAGS + --amount int How much to spend + + EXAMPLES + $ gh spend --amount 1 + + LEARN MORE + Use ` + "`gh --help`" + ` for more information about a command. + Read the manual at https://cli.github.com/manual + Learn about exit codes using ` + "`gh help exit-codes`" + ` + Learn about accessibility experiences using ` + "`gh help accessibility`" + ` + + `), + }, + { + name: "unknown Cobra command error with full help", + args: args{ + err: errors.New("unknown command foo"), + cmd: cmd, + debug: false, + fullHelp: true, + }, + wantOut: heredoc.Doc(` + unknown command foo + + Spend money + + USAGE + gh spend [flags] + + FLAGS + --amount int How much to spend + + EXAMPLES + $ gh spend --amount 1 + + LEARN MORE + Use ` + "`gh --help`" + ` for more information about a command. + Read the manual at https://cli.github.com/manual + Learn about exit codes using ` + "`gh help exit-codes`" + ` + Learn about accessibility experiences using ` + "`gh help accessibility`" + ` + + `), + }, + { + name: "generic error is unaffected by full help", + args: args{ + err: errors.New("the app exploded"), + cmd: cmd, + debug: false, + fullHelp: true, + }, + wantOut: "the app exploded\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() out := &bytes.Buffer{} - printError(out, tt.args.err, tt.args.cmd, tt.args.debug) - if gotOut := out.String(); gotOut != tt.wantOut { - t.Errorf("printError() = %q, want %q", gotOut, tt.wantOut) - } + printError(out, ios.ColorScheme(), tt.args.err, tt.args.cmd, tt.args.debug, tt.args.fullHelp) + assert.Equal(t, tt.wantOut, out.String()) }) } } diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index 2676cdd1517..d1ac34ebc67 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -109,6 +110,14 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { return } + WriteHelp(f.IOStreams.Out, cs, command) +} + +// WriteHelp renders the full help text for command to w. This is the same +// output produced by `gh --help`, exposed separately so that callers +// such as error reporting can render help to a stream of their choosing rather +// than always writing to stdout. +func WriteHelp(w io.Writer, cs *iostreams.ColorScheme, command *cobra.Command) { type helpEntry struct { Title string Body string @@ -193,17 +202,16 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { Learn about accessibility experiences using %[1]sgh help accessibility%[1]s `, "`")}) - out := f.IOStreams.Out for _, e := range helpEntries { if e.Title != "" { // If there is a title, add indentation to each line in the body - fmt.Fprintln(out, cs.Bold(e.Title)) - fmt.Fprintln(out, text.Indent(strings.Trim(e.Body, "\r\n"), " ")) + fmt.Fprintln(w, cs.Bold(e.Title)) + fmt.Fprintln(w, text.Indent(strings.Trim(e.Body, "\r\n"), " ")) } else { // If there is no title print the body as is - fmt.Fprintln(out, e.Body) + fmt.Fprintln(w, e.Body) } - fmt.Fprintln(out) + fmt.Fprintln(w) } } From 3a73af2677163bebb2bdc5bb1798a746bd552d53 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 21 Aug 2026 14:57:08 +0200 Subject: [PATCH 2/2] Resolve aliases to their target when rendering full help on error A user-defined alias is a stub command: its own help carries no flags or examples, only "Alias for ...". NewCmdRoot works around this by pointing the alias's usage and help funcs at the command it expands to, but printError calls root.WriteHelp directly and so bypassed that redirection. The result was that agents got strictly worse output than humans for aliases: `gh prs --badFlag` rendered 384 bytes of alias stub with none of `pr list`'s flags, leaving the agent no way to correct itself. Resolve the alias inside WriteHelp so the behaviour cannot be missed by a future call site. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6afb36c-b3b5-472f-b540-025e38395b79 --- pkg/cmd/root/alias.go | 3 ++ pkg/cmd/root/help.go | 30 ++++++++++++++++ pkg/cmd/root/help_test.go | 72 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/pkg/cmd/root/alias.go b/pkg/cmd/root/alias.go index ea4c21d8a97..c8fd4c2d173 100644 --- a/pkg/cmd/root/alias.go +++ b/pkg/cmd/root/alias.go @@ -66,6 +66,9 @@ func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.C }, GroupID: "alias", DisableFlagParsing: true, + Annotations: map[string]string{ + aliasExpansionAnnotation: aliasValue, + }, } cmdutil.DisableAuthCheck(cmd) // Aliases are user-defined names and must not be reported as telemetry diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index d1ac34ebc67..7a1dcdb6612 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -117,7 +118,13 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { // output produced by `gh --help`, exposed separately so that callers // such as error reporting can render help to a stream of their choosing rather // than always writing to stdout. +// +// A user-defined alias is resolved to the command it expands to, so that +// callers see the target's flags and examples rather than the alias stub. This +// mirrors the usage and help funcs configured for aliases in NewCmdRoot. func WriteHelp(w io.Writer, cs *iostreams.ColorScheme, command *cobra.Command) { + command = resolveAliasTarget(command) + type helpEntry struct { Title string Body string @@ -340,3 +347,26 @@ func BuildAliasList(cmd *cobra.Command, aliases []string) []string { return BuildAliasList(cmd.Parent(), aliasesWithParentAliases) } + +// aliasExpansionAnnotation records the command line a user-defined alias +// expands to, so that help rendering can resolve the alias to its target. +const aliasExpansionAnnotation = "gh:alias-expansion" + +// resolveAliasTarget returns the command a user-defined alias expands to, or +// command unchanged when it is not an alias or the target cannot be resolved. +// Shell aliases have no target command and are always returned unchanged. +func resolveAliasTarget(command *cobra.Command) *cobra.Command { + expansion, ok := command.Annotations[aliasExpansionAnnotation] + if !ok { + return command + } + args, err := shlex.Split(expansion) + if err != nil || len(args) == 0 { + return command + } + target, _, err := command.Root().Find(args) + if err != nil || target == nil { + return command + } + return target +} diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index 0b73d7a438d..495d2d113d9 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -1,6 +1,7 @@ package root import ( + "bytes" "fmt" "testing" @@ -123,3 +124,74 @@ func assertPipesAreInCodeBlocks(t *testing.T, cmd *cobra.Command) { checkNode(doc) } + +func TestWriteHelp_aliasResolution(t *testing.T) { + newRoot := func() (*cobra.Command, *iostreams.IOStreams) { + ios, _, _, _ := iostreams.Test() + rootCmd := &cobra.Command{Use: "gh"} + prCmd := &cobra.Command{Use: "pr", Short: "Manage pull requests"} + listCmd := &cobra.Command{Use: "list", Short: "List pull requests", Run: func(*cobra.Command, []string) {}} + listCmd.Flags().Int("limit", 30, "Maximum number of items to fetch") + prCmd.AddCommand(listCmd) + rootCmd.AddCommand(prCmd) + return rootCmd, ios + } + + t.Run("configured alias renders the target command help", func(t *testing.T) { + rootCmd, ios := newRoot() + aliasCmd := NewCmdAlias(ios, "prs", "pr list") + rootCmd.AddCommand(aliasCmd) + + var buf bytes.Buffer + WriteHelp(&buf, ios.ColorScheme(), aliasCmd) + + out := buf.String() + require.Contains(t, out, "gh pr list") + require.Contains(t, out, "--limit") + require.NotContains(t, out, `Alias for "pr list"`) + }) + + t.Run("alias expansion carrying flags still resolves to the target", func(t *testing.T) { + rootCmd, ios := newRoot() + aliasCmd := NewCmdAlias(ios, "prs", "pr list --limit 5") + rootCmd.AddCommand(aliasCmd) + + var buf bytes.Buffer + WriteHelp(&buf, ios.ColorScheme(), aliasCmd) + + require.Contains(t, buf.String(), "gh pr list") + }) + + t.Run("shell alias has no target and renders its own help", func(t *testing.T) { + rootCmd, ios := newRoot() + aliasCmd := NewCmdShellAlias(ios, "sh", "!echo hi") + rootCmd.AddCommand(aliasCmd) + + var buf bytes.Buffer + WriteHelp(&buf, ios.ColorScheme(), aliasCmd) + + require.Contains(t, buf.String(), "gh sh") + }) + + t.Run("alias expanding to an unknown command falls back to the alias", func(t *testing.T) { + rootCmd, ios := newRoot() + aliasCmd := NewCmdAlias(ios, "zz", "nope missing") + rootCmd.AddCommand(aliasCmd) + + var buf bytes.Buffer + WriteHelp(&buf, ios.ColorScheme(), aliasCmd) + + require.Contains(t, buf.String(), "gh zz") + }) + + t.Run("non-alias command is unaffected", func(t *testing.T) { + rootCmd, ios := newRoot() + target, _, err := rootCmd.Find([]string{"pr", "list"}) + require.NoError(t, err) + + var buf bytes.Buffer + WriteHelp(&buf, ios.ColorScheme(), target) + + require.Contains(t, buf.String(), "gh pr list") + }) +}