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/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 2676cdd1517..7a1dcdb6612 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -11,6 +11,8 @@ 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/google/shlex" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -109,6 +111,20 @@ 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. +// +// 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 @@ -193,17 +209,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) } } @@ -332,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") + }) +}