Repository: cli/cli
Pull request #7457 (2023-05-17 05:05:17)

Allow nested aliases
This PR adds the ability to create aliases that are nested deeper than top level, for example `gh pr co`. I broke the PR down into two commits.

The first commit is a refactor to set up this functionality. Previously when invoking an extension or alias we interrupted the standard command dispatching to either short-circuit it or inject additional arguments to make sure the extension or alias was called. I changed this behavior. Now, aliases and extensions get dynamically mounted as commands just like any standard command. This means that we no longer have to do anything fancy at dispatch time and the logic becomes more straightforward. Including not having to do anything crazy to get completions for them like we used to do. This also sets us up to easily allow for nested extension commands in the future.

The second commit adds the ability to actually set aliases that are deeply nested, as well as cleaning up a couple error messages to be more accurate with the the new state of having extensions and aliases mounted as commands. Users will also now be able to create aliases for extensions and other aliases if they are so inclined. The flexibility here due to the refactoring is pretty amazing, a user can create a whole suite of aliases such as `gh my issues`, `gh my prs`, `gh my repos`. 

This PR kind of confusing as it spans a lot of files, it is easier to follow if viewed by commit. 

Closes https://github.com/cli/cli/issues/1303


Patch:
diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go
index 7c5078df9..f3a12ef76 100644
--- a/cmd/gen-docs/main.go
+++ b/cmd/gen-docs/main.go
@@ -2,13 +2,17 @@ package main
 
 import (
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"strings"
 
+	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/docs"
+	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/cmd/root"
 	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/extensions"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/spf13/pflag"
 )
@@ -41,9 +45,13 @@ func run(args []string) error {
 	}
 
 	ios, _, _, _ := iostreams.Test()
-	rootCmd := root.NewCmdRoot(&cmdutil.Factory{
+	rootCmd, _ := root.NewCmdRoot(&cmdutil.Factory{
 		IOStreams: ios,
 		Browser:   &browser{},
+		Config: func() (config.Config, error) {
+			return config.NewBlankConfig(), nil
+		},
+		ExtensionManager: &em{},
 	}, "", "")
 	rootCmd.InitDefaultHelpCmd()
 
@@ -79,8 +87,42 @@ func linkHandler(name string) string {
 	return fmt.Sprintf("./%s", strings.TrimSuffix(name, ".md"))
 }
 
+// Implements browser.Browser interface.
 type browser struct{}
 
-func (b *browser) Browse(url string) error {
+func (b *browser) Browse(_ string) error {
 	return nil
 }
+
+// Implements extensions.ExtensionManager interface.
+type em struct{}
+
+func (e *em) List() []extensions.Extension {
+	return nil
+}
+
+func (e *em) Install(_ ghrepo.Interface, _ string) error {
+	return nil
+}
+
+func (e *em) InstallLocal(_ string) error {
+	return nil
+}
+
+func (e *em) Upgrade(_ string, _ bool) error {
+	return nil
+}
+
+func (e *em) Remove(_ string) error {
+	return nil
+}
+
+func (e *em) Dispatch(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) {
+	return false, nil
+}
+
+func (e *em) Create(_ string, _ extensions.ExtTemplateType) error {
+	return nil
+}
+
+func (e *em) EnableDryRunMode() {}
diff --git a/cmd/gh/main.go b/cmd/gh/main.go
index f231d7d2c..c36a4e6e6 100644
--- a/cmd/gh/main.go
+++ b/cmd/gh/main.go
@@ -14,16 +14,10 @@ import (
 
 	surveyCore "github.com/AlecAivazis/survey/v2/core"
 	"github.com/AlecAivazis/survey/v2/terminal"
-	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
-	"github.com/cli/cli/v2/git"
 	"github.com/cli/cli/v2/internal/build"
 	"github.com/cli/cli/v2/internal/config"
-	"github.com/cli/cli/v2/internal/ghrepo"
-	"github.com/cli/cli/v2/internal/run"
-	"github.com/cli/cli/v2/internal/text"
 	"github.com/cli/cli/v2/internal/update"
-	"github.com/cli/cli/v2/pkg/cmd/alias/expand"
 	"github.com/cli/cli/v2/pkg/cmd/factory"
 	"github.com/cli/cli/v2/pkg/cmd/root"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -93,11 +87,9 @@ func mainRun() exitCode {
 		cobra.MousetrapHelpText = ""
 	}
 
-	rootCmd := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
-
-	cfg, err := cmdFactory.Config()
+	rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
 	if err != nil {
-		fmt.Fprintf(stderr, "failed to read configuration:  %s\n", err)
+		fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
 		return exitError
 	}
 
@@ -106,110 +98,10 @@ func mainRun() exitCode {
 		expandedArgs = os.Args[1:]
 	}
 
-	// translate `gh help <command>` to `gh <command> --help` for extensions
-	if len(expandedArgs) == 2 && expandedArgs[0] == "help" && !hasCommand(rootCmd, expandedArgs[1:]) {
-		expandedArgs = []string{expandedArgs[1], "--help"}
-	}
-
-	if !hasCommand(rootCmd, expandedArgs) {
-		originalArgs := expandedArgs
-		isShell := false
-
-		argsForExpansion := append([]string{"gh"}, expandedArgs...)
-		expandedArgs, isShell, err = expand.ExpandAlias(cfg, argsForExpansion, nil)
-		if err != nil {
-			fmt.Fprintf(stderr, "failed to process aliases:  %s\n", err)
-			return exitError
-		}
-
-		if hasDebug {
-			fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
-		}
-
-		if isShell {
-			exe, err := safeexec.LookPath(expandedArgs[0])
-			if err != nil {
-				fmt.Fprintf(stderr, "failed to run external command: %s", err)
-				return exitError
-			}
-
-			externalCmd := exec.Command(exe, expandedArgs[1:]...)
-			externalCmd.Stderr = os.Stderr
-			externalCmd.Stdout = os.Stdout
-			externalCmd.Stdin = os.Stdin
-			preparedCmd := run.PrepareCmd(externalCmd)
-
-			err = preparedCmd.Run()
-			if err != nil {
-				var execError *exec.ExitError
-				if errors.As(err, &execError) {
-					return exitCode(execError.ExitCode())
-				}
-				fmt.Fprintf(stderr, "failed to run external command: %s\n", err)
-				return exitError
-			}
-
-			return exitOK
-		} else if len(expandedArgs) > 0 && !hasCommand(rootCmd, expandedArgs) {
-			extensionManager := cmdFactory.ExtensionManager
-			if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
-				var execError *exec.ExitError
-				if errors.As(err, &execError) {
-					return exitCode(execError.ExitCode())
-				}
-				fmt.Fprintf(stderr, "failed to run extension: %s\n", err)
-				return exitError
-			} else if found {
-				return exitOK
-			}
-		}
-	}
-
-	// provide completions for aliases and extensions
-	rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
-		var results []string
-		aliases := cfg.Aliases()
-		for aliasName, aliasValue := range aliases.All() {
-			if strings.HasPrefix(aliasName, toComplete) {
-				var s string
-				if strings.HasPrefix(aliasValue, "!") {
-					s = fmt.Sprintf("%s\tShell alias", aliasName)
-				} else {
-					aliasValue = text.Truncate(80, aliasValue)
-					s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)
-				}
-				results = append(results, s)
-			}
-		}
-		for _, ext := range cmdFactory.ExtensionManager.List() {
-			if strings.HasPrefix(ext.Name(), toComplete) {
-				var s string
-				if ext.IsLocal() {
-					s = fmt.Sprintf("%s\tLocal extension gh-%s", ext.Name(), ext.Name())
-				} else {
-					path := ext.URL()
-					if u, err := git.ParseURL(ext.URL()); err == nil {
-						if r, err := ghrepo.FromURL(u); err == nil {
-							path = ghrepo.FullName(r)
-						}
-					}
-					s = fmt.Sprintf("%s\tExtension %s", ext.Name(), path)
-				}
-				results = append(results, s)
-			}
-		}
-		return results, cobra.ShellCompDirectiveNoFileComp
-	}
-
-	authError := errors.New("authError")
-	rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
-		// require that the user is authenticated before running most commands
-		if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
-			fmt.Fprint(stderr, authHelp())
-			return authError
-		}
-
-		return nil
+	// translate `gh help <command>` to `gh <command> --help` for extensions.
+	if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) {
+		expandedArgs = expandedArgs[1:]
+		expandedArgs = append(expandedArgs, "--help")
 	}
 
 	rootCmd.SetArgs(expandedArgs)
@@ -217,6 +109,8 @@ func mainRun() exitCode {
 	if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
 		var pagerPipeError *iostreams.ErrClosedPagerPipe
 		var noResultsError cmdutil.NoResultsError
+		var execError *exec.ExitError
+		var authError *root.AuthError
 		if err == cmdutil.SilentError {
 			return exitError
 		} else if cmdutil.IsUserCancellation(err) {
@@ -225,7 +119,7 @@ func mainRun() exitCode {
 				fmt.Fprint(stderr, "\n")
 			}
 			return exitCancel
-		} else if errors.Is(err, authError) {
+		} else if errors.As(err, &authError) {
 			return exitAuth
 		} else if errors.As(err, &pagerPipeError) {
 			// ignore the error raised when piping to a closed pager
@@ -236,6 +130,8 @@ func mainRun() exitCode {
 			}
 			// no results is not a command failure
 			return exitOK
+		} else if errors.As(err, &execError) {
+			return exitCode(execError.ExitCode())
 		}
 
 		printError(stderr, err, cmd, hasDebug)
@@ -284,10 +180,10 @@ func mainRun() exitCode {
 	return exitOK
 }
 
-// hasCommand returns true if args resolve to a built-in command
-func hasCommand(rootCmd *cobra.Command, args []string) bool {
-	c, _, err := rootCmd.Traverse(args)
-	return err == nil && c != rootCmd
+// isExtensionCommand returns true if args resolve to an extension command.
+func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
+	c, _, err := rootCmd.Find(args)
+	return err == nil && c != nil && c.GroupID == "extension"
 }
 
 func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
@@ -312,27 +208,6 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
 	}
 }
 
-func authHelp() string {
-	if os.Getenv("GITHUB_ACTIONS") == "true" {
-		return heredoc.Doc(`
-			gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
-			  env:
-			    GH_TOKEN: ${{ github.token }}
-		`)
-	}
-
-	if os.Getenv("CI") != "" {
-		return heredoc.Doc(`
-			gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
-		`)
-	}
-
-	return heredoc.Doc(`
-		To get started with GitHub CLI, please run:  gh auth login
-		Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
-	`)
-}
-
 func shouldCheckForUpdate() bool {
 	if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
 		return false
diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go
index b72a1a8f8..cd90fcd2d 100644
--- a/internal/prompter/prompter.go
+++ b/internal/prompter/prompter.go
@@ -14,7 +14,7 @@ import (
 //go:generate moq -rm -out prompter_mock.go . Prompter
 type Prompter interface {
 	Select(string, string, []string) (int, error)
-	MultiSelect(string, string, []string) ([]string, error)
+	MultiSelect(string, []string, []string) ([]int, error)
 	Input(string, string) (string, error)
 	InputHostname() (string, error)
 	Password(string) (string, error)
@@ -86,7 +86,7 @@ func (p *surveyPrompter) Select(message, defaultValue string, options []string)
 	return
 }
 
-func (p *surveyPrompter) MultiSelect(message, defaultValue string, options []string) (result []string, err error) {
+func (p *surveyPrompter) MultiSelect(message string, defaultValues, options []string) (result []int, err error) {
 	q := &survey.MultiSelect{
 		Message:  message,
 		Options:  options,
@@ -94,8 +94,17 @@ func (p *surveyPrompter) MultiSelect(message, defaultValue string, options []str
 		Filter:   LatinMatchingFilter,
 	}
 
-	if defaultValue != "" {
-		q.Default = defaultValue
+	if len(defaultValues) > 0 {
+		// TODO I don't actually know that this is needed, just being extra cautious
+		validatedDefault := []string{}
+		for _, x := range defaultValues {
+			for _, y := range options {
+				if x == y {
+					validatedDefault = append(validatedDefault, x)
+				}
+			}
+		}
+		q.Default = validatedDefault
 	}
 
 	err = p.ask(q, &result)
diff --git a/internal/prompter/prompter_mock.go b/internal/prompter/prompter_mock.go
index 859832450..9a59a7bd1 100644
--- a/internal/prompter/prompter_mock.go
+++ b/internal/prompter/prompter_mock.go
@@ -35,7 +35,7 @@ var _ Prompter = &PrompterMock{}
 //			MarkdownEditorFunc: func(s1 string, s2 string, b bool) (string, error) {
 //				panic("mock out the MarkdownEditor method")
 //			},
-//			MultiSelectFunc: func(s1 string, s2 string, strings []string) ([]string, error) {
+//			MultiSelectFunc: func(s string, strings1 []string, strings2 []string) ([]int, error) {
 //				panic("mock out the MultiSelect method")
 //			},
 //			PasswordFunc: func(s string) (string, error) {
@@ -70,7 +70,7 @@ type PrompterMock struct {
 	MarkdownEditorFunc func(s1 string, s2 string, b bool) (string, error)
 
 	// MultiSelectFunc mocks the MultiSelect method.
-	MultiSelectFunc func(s1 string, s2 string, strings []string) ([]string, error)
+	MultiSelectFunc func(s string, strings1 []string, strings2 []string) ([]int, error)
 
 	// PasswordFunc mocks the Password method.
 	PasswordFunc func(s string) (string, error)
@@ -116,12 +116,12 @@ type PrompterMock struct {
 		}
 		// MultiSelect holds details about calls to the MultiSelect method.
 		MultiSelect []struct {
-			// S1 is the s1 argument value.
-			S1 string
-			// S2 is the s2 argument value.
-			S2 string
-			// Strings is the strings argument value.
-			Strings []string
+			// S is the s argument value.
+			S string
+			// Strings1 is the strings1 argument value.
+			Strings1 []string
+			// Strings2 is the strings2 argument value.
+			Strings2 []string
 		}
 		// Password holds details about calls to the Password method.
 		Password []struct {
@@ -348,23 +348,23 @@ func (mock *PrompterMock) MarkdownEditorCalls() []struct {
 }
 
 // MultiSelect calls MultiSelectFunc.
-func (mock *PrompterMock) MultiSelect(s1 string, s2 string, strings []string) ([]string, error) {
+func (mock *PrompterMock) MultiSelect(s string, strings1 []string, strings2 []string) ([]int, error) {
 	if mock.MultiSelectFunc == nil {
 		panic("PrompterMock.MultiSelectFunc: method is nil but Prompter.MultiSelect was just called")
 	}
 	callInfo := struct {
-		S1      string
-		S2      string
-		Strings []string
+		S        string
+		Strings1 []string
+		Strings2 []string
 	}{
-		S1:      s1,
-		S2:      s2,
-		Strings: strings,
+		S:        s,
+		Strings1: strings1,
+		Strings2: strings2,
 	}
 	mock.lockMultiSelect.Lock()
 	mock.calls.MultiSelect = append(mock.calls.MultiSelect, callInfo)
 	mock.lockMultiSelect.Unlock()
-	return mock.MultiSelectFunc(s1, s2, strings)
+	return mock.MultiSelectFunc(s, strings1, strings2)
 }
 
 // MultiSelectCalls gets all the calls that were made to MultiSelect.
@@ -372,14 +372,14 @@ func (mock *PrompterMock) MultiSelect(s1 string, s2 string, strings []string) ([
 //
 //	len(mockedPrompter.MultiSelectCalls())
 func (mock *PrompterMock) MultiSelectCalls() []struct {
-	S1      string
-	S2      string
-	Strings []string
+	S        string
+	Strings1 []string
+	Strings2 []string
 } {
 	var calls []struct {
-		S1      string
-		S2      string
-		Strings []string
+		S        string
+		Strings1 []string
+		Strings2 []string
 	}
 	mock.lockMultiSelect.RLock()
 	calls = mock.calls.MultiSelect
diff --git a/pkg/cmd/alias/expand/expand.go b/pkg/cmd/alias/expand/expand.go
deleted file mode 100644
index d0e2627fb..000000000
--- a/pkg/cmd/alias/expand/expand.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package expand
-
-import (
-	"errors"
-	"fmt"
-	"os/exec"
-	"regexp"
-	"runtime"
-	"strings"
-
-	"github.com/cli/cli/v2/internal/config"
-	"github.com/cli/cli/v2/pkg/findsh"
-	"github.com/google/shlex"
-)
-
-// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The
-// second return value indicates whether the alias should be executed in a new shell process instead
-// of running gh itself.
-func ExpandAlias(cfg config.Config, args []string, findShFunc func() (string, error)) (expanded []string, isShell bool, err error) {
-	if len(args) < 2 {
-		// the command is lacking a subcommand
-		return
-	}
-	expanded = args[1:]
-
-	aliases := cfg.Aliases()
-
-	expansion, getErr := aliases.Get(args[1])
-	if getErr != nil {
-		return
-	}
-
-	if strings.HasPrefix(expansion, "!") {
-		isShell = true
-		if findShFunc == nil {
-			findShFunc = findSh
-		}
-		shPath, shErr := findShFunc()
-		if shErr != nil {
-			err = shErr
-			return
-		}
-
-		expanded = []string{shPath, "-c", expansion[1:]}
-
-		if len(args[2:]) > 0 {
-			expanded = append(expanded, "--")
-			expanded = append(expanded, args[2:]...)
-		}
-
-		return
-	}
-
-	extraArgs := []string{}
-	for i, a := range args[2:] {
-		if !strings.Contains(expansion, "$") {
-			extraArgs = append(extraArgs, a)
-		} else {
-			expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a)
-		}
-	}
-	lingeringRE := regexp.MustCompile(`\$\d`)
-	if lingeringRE.MatchString(expansion) {
-		err = fmt.Errorf("not enough arguments for alias: %s", expansion)
-		return
-	}
-
-	var newArgs []string
-	newArgs, err = shlex.Split(expansion)
-	if err != nil {
-		return
-	}
-
-	expanded = append(newArgs, extraArgs...)
-	return
-}
-
-func findSh() (string, error) {
-	shPath, err := findsh.Find()
-	if err != nil {
-		if errors.Is(err, exec.ErrNotFound) {
-			if runtime.GOOS == "windows" {
-				return "", errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
-			}
-			return "", errors.New("unable to locate sh to execute shell alias with")
-		}
-		return "", err
-	}
-	return shPath, nil
-}
diff --git a/pkg/cmd/alias/imports/import.go b/pkg/cmd/alias/imports/import.go
index f724e2699..f7de0bb78 100644
--- a/pkg/cmd/alias/imports/import.go
+++ b/pkg/cmd/alias/imports/import.go
@@ -21,7 +21,8 @@ type ImportOptions struct {
 	Filename          string
 	OverwriteExisting bool
 
-	existingCommand func(string) bool
+	validAliasName      func(string) bool
+	validAliasExpansion func(string) bool
 }
 
 func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command {
@@ -74,7 +75,8 @@ func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Co
 				opts.Filename = args[0]
 			}
 
-			opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
+			opts.validAliasName = shared.ValidAliasNameFunc(cmd)
+			opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd)
 
 			if runF != nil {
 				return runF(opts)
@@ -120,9 +122,9 @@ func importRun(opts *ImportOptions) error {
 	var msg strings.Builder
 
 	for _, alias := range getSortedKeys(aliasMap) {
-		if opts.existingCommand(alias) {
+		if !opts.validAliasName(alias) {
 			msg.WriteString(
-				fmt.Sprintf("%s Could not import alias %s: already a gh command\n",
+				fmt.Sprintf("%s Could not import alias %s: already a gh command, extension, or alias\n",
 					cs.FailureIcon(),
 					cs.Bold(alias),
 				),
@@ -133,9 +135,9 @@ func importRun(opts *ImportOptions) error {
 
 		expansion := aliasMap[alias]
 
-		if !(strings.HasPrefix(expansion, "!") || opts.existingCommand(expansion)) {
+		if !opts.validAliasExpansion(expansion) {
 			msg.WriteString(
-				fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command\n",
+				fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command, extension, or alias\n",
 					cs.FailureIcon(),
 					cs.Bold(alias),
 				),
diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go
index 871737881..20c86c473 100644
--- a/pkg/cmd/alias/set/set.go
+++ b/pkg/cmd/alias/set/set.go
@@ -21,7 +21,8 @@ type SetOptions struct {
 	Expansion string
 	IsShell   bool
 
-	existingCommand func(string) bool
+	validAliasName      func(string) bool
+	validAliasExpansion func(string) bool
 }
 
 func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
@@ -59,6 +60,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
 			$ gh alias set homework 'issue list --assignee @me'
 			$ gh homework
 
+			$ gh alias set 'issue mine' 'issue list --mention @me'
+			$ gh issue mine
+
 			$ gh alias set epicsBy 'issue list --author="$1" --label="epic"'
 			$ gh epicsBy vilmibm  #=> gh issue list --author="vilmibm" --label="epic"
 
@@ -70,7 +74,8 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
 			opts.Name = args[0]
 			opts.Expansion = args[1]
 
-			opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
+			opts.validAliasName = shared.ValidAliasNameFunc(cmd)
+			opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd)
 
 			if runF != nil {
 				return runF(opts)
@@ -104,18 +109,16 @@ func setRun(opts *SetOptions) error {
 		fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion))
 	}
 
-	isShell := opts.IsShell
-	if isShell && !strings.HasPrefix(expansion, "!") {
+	if opts.IsShell && !strings.HasPrefix(expansion, "!") {
 		expansion = "!" + expansion
 	}
-	isShell = strings.HasPrefix(expansion, "!")
 
-	if opts.existingCommand(opts.Name) {
-		return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
+	if !opts.validAliasName(opts.Name) {
+		return fmt.Errorf("could not create alias: %q is already a gh command, extension, or alias", opts.Name)
 	}
 
-	if !isShell && !opts.existingCommand(expansion) {
-		return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
+	if !opts.validAliasExpansion(expansion) {
+		return fmt.Errorf("could not create alias: %s does not correspond to a gh command, extension, or alias", expansion)
 	}
 
 	successMsg := fmt.Sprintf("%s Added alias.", cs.SuccessIcon())
diff --git a/pkg/cmd/alias/shared/validations.go b/pkg/cmd/alias/shared/validations.go
index 6557f66ca..9a213ab98 100644
--- a/pkg/cmd/alias/shared/validations.go
+++ b/pkg/cmd/alias/shared/validations.go
@@ -1,14 +1,18 @@
 package shared
 
 import (
-	"github.com/cli/cli/v2/pkg/cmdutil"
+	"strings"
+
 	"github.com/google/shlex"
 	"github.com/spf13/cobra"
 )
 
-// ExistingCommandFunc returns a function that will check if the given string
-// corresponds to an existing command.
-func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bool {
+// ValidAliasNameFunc returns a function that will check if the given string
+// is a valid alias name. A name is valid if:
+//   - it does not shadow an existing command,
+//   - it is not nested under a command that is runnable,
+//   - it is not nested under a command that does not exist.
+func ValidAliasNameFunc(cmd *cobra.Command) func(string) bool {
 	return func(args string) bool {
 		split, err := shlex.Split(args)
 		if err != nil || len(split) == 0 {
@@ -16,17 +20,32 @@ func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bo
 		}
 
 		rootCmd := cmd.Root()
-		cmd, _, err = rootCmd.Traverse(split)
-		if err == nil && cmd != rootCmd {
+		foundCmd, foundArgs, _ := rootCmd.Find(split)
+		if foundCmd != nil && !foundCmd.Runnable() && len(foundArgs) == 1 {
+			return true
+		}
+
+		return false
+	}
+}
+
+// ValidAliasExpansionFunc returns a function that will check if the given string
+// is a valid alias expansion. An expansion is valid if:
+//   - it is a shell expansion,
+//   - it is a non-shell expansion that corresponds to an existing command, extension, or alias.
+func ValidAliasExpansionFunc(cmd *cobra.Command) func(string) bool {
+	return func(expansion string) bool {
+		if strings.HasPrefix(expansion, "!") {
 			return true
 		}
 
-		for _, ext := range f.ExtensionManager.List() {
-			if ext.Name() == split[0] {
-				return true
-			}
+		split, err := shlex.Split(expansion)
+		if err != nil || len(split) == 0 {
+			return false
 		}
 
-		return false
+		rootCmd := cmd.Root()
+		cmd, _, _ = rootCmd.Find(split)
+		return cmd != rootCmd
 	}
 }
diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go
index 0f2add8aa..6629e599d 100644
--- a/pkg/cmd/extension/command.go
+++ b/pkg/cmd/extension/command.go
@@ -643,10 +643,8 @@ func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager,
 	}
 
 	commandName := strings.TrimPrefix(extName, "gh-")
-	if c, _, err := rootCmd.Traverse([]string{commandName}); err != nil {
-		return nil, err
-	} else if c != rootCmd {
-		return nil, fmt.Errorf("%q matches the name of a built-in command", commandName)
+	if c, _, _ := rootCmd.Find([]string{commandName}); c != rootCmd && c.GroupID != "extension" {
+		return nil, fmt.Errorf("%q matches the name of a built-in command or alias", commandName)
 	}
 
 	for _, ext := range m.List() {
diff --git a/pkg/cmd/root/alias.go b/pkg/cmd/root/alias.go
new file mode 100644
index 000000000..ba0bfe9e2
--- /dev/null
+++ b/pkg/cmd/root/alias.go
@@ -0,0 +1,126 @@
+package root
+
+import (
+	"errors"
+	"fmt"
+	"os/exec"
+	"regexp"
+	"runtime"
+	"strings"
+
+	"github.com/cli/cli/v2/internal/run"
+	"github.com/cli/cli/v2/internal/text"
+	"github.com/cli/cli/v2/pkg/findsh"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/spf13/cobra"
+)
+
+func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command {
+	return &cobra.Command{
+		Use:   aliasName,
+		Short: fmt.Sprintf("Shell alias for %q", text.Truncate(80, aliasValue)),
+		RunE: func(c *cobra.Command, args []string) error {
+			expandedArgs, err := expandShellAlias(aliasValue, args, nil)
+			if err != nil {
+				return err
+			}
+			externalCmd := exec.Command(expandedArgs[0], expandedArgs[1:]...)
+			externalCmd.Stderr = io.ErrOut
+			externalCmd.Stdout = io.Out
+			externalCmd.Stdin = io.In
+			preparedCmd := run.PrepareCmd(externalCmd)
+			if err = preparedCmd.Run(); err != nil {
+				return fmt.Errorf("failed to run external command: %w\n", err)
+			}
+			return nil
+		},
+		GroupID: "alias",
+		Annotations: map[string]string{
+			"skipAuthCheck": "true",
+		},
+		DisableFlagParsing: true,
+	}
+}
+
+func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command {
+	return &cobra.Command{
+		Use:   aliasName,
+		Short: fmt.Sprintf("Alias for %q", text.Truncate(80, aliasValue)),
+		RunE: func(c *cobra.Command, args []string) error {
+			expandedArgs, err := expandAlias(aliasValue, args)
+			if err != nil {
+				return err
+			}
+			root := c.Root()
+			root.SetArgs(expandedArgs)
+			return root.Execute()
+		},
+		GroupID: "alias",
+		Annotations: map[string]string{
+			"skipAuthCheck": "true",
+		},
+		DisableFlagParsing: true,
+	}
+}
+
+// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases.
+func expandAlias(expansion string, args []string) ([]string, error) {
+	extraArgs := []string{}
+	for i, a := range args {
+		if !strings.Contains(expansion, "$") {
+			extraArgs = append(extraArgs, a)
+		} else {
+			expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a)
+		}
+	}
+
+	lingeringRE := regexp.MustCompile(`\$\d`)
+	if lingeringRE.MatchString(expansion) {
+		return nil, fmt.Errorf("not enough arguments for alias: %s", expansion)
+	}
+
+	newArgs, err := shlex.Split(expansion)
+	if err != nil {
+		return nil, err
+	}
+
+	expanded := append(newArgs, extraArgs...)
+
+	return expanded, nil
+}
+
+// ExpandShellAlias processes argv to see if it should be rewritten according to a user's aliases.
+func expandShellAlias(expansion string, args []string, findShFunc func() (string, error)) ([]string, error) {
+	if findShFunc == nil {
+		findShFunc = findSh
+	}
+
+	shPath, shErr := findShFunc()
+	if shErr != nil {
+		return nil, shErr
+	}
+
+	expanded := []string{shPath, "-c", expansion[1:]}
+
+	if len(args) > 0 {
+		expanded = append(expanded, "--")
+		expanded = append(expanded, args...)
+	}
+
+	return expanded, nil
+}
+
+func findSh() (string, error) {
+	shPath, err := findsh.Find()
+	if err != nil {
+		if errors.Is(err, exec.ErrNotFound) {
+			if runtime.GOOS == "windows" {
+				return "", errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
+			}
+			return "", errors.New("unable to locate sh to execute shell alias with")
+		}
+		return "", err
+	}
+	return shPath, nil
+}
diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go
new file mode 100644
index 000000000..52ef6b0a4
--- /dev/null
+++ b/pkg/cmd/root/extension.go
@@ -0,0 +1,42 @@
+package root
+
+import (
+	"fmt"
+
+	"github.com/cli/cli/v2/git"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/extensions"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ext extensions.Extension) *cobra.Command {
+	var short string
+	if ext.IsLocal() {
+		short = fmt.Sprintf("Local extension gh-%s", ext.Name())
+	} else {
+		path := ext.URL()
+		if u, err := git.ParseURL(ext.URL()); err == nil {
+			if r, err := ghrepo.FromURL(u); err == nil {
+				path = ghrepo.FullName(r)
+			}
+		}
+		short = fmt.Sprintf("Extension %s", path)
+	}
+	return &cobra.Command{
+		Use:   ext.Name(),
+		Short: short,
+		RunE: func(c *cobra.Command, args []string) error {
+			args = append([]string{ext.Name()}, args...)
+			if _, err := em.Dispatch(args, io.In, io.Out, io.ErrOut); err != nil {
+				return fmt.Errorf("failed to run extension: %w\n", err)
+			}
+			return nil
+		},
+		GroupID: "extension",
+		Annotations: map[string]string{
+			"skipAuthCheck": "true",
+		},
+		DisableFlagParsing: true,
+	}
+}
diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go
index 9da57d6e9..c33095c1f 100644
--- a/pkg/cmd/root/help.go
+++ b/pkg/cmd/root/help.go
@@ -4,9 +4,11 @@ import (
 	"bytes"
 	"fmt"
 	"io"
+	"os"
 	"sort"
 	"strings"
 
+	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/text"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/spf13/cobra"
@@ -16,13 +18,17 @@ import (
 func rootUsageFunc(w io.Writer, command *cobra.Command) error {
 	fmt.Fprintf(w, "Usage:  %s", command.UseLine())
 
-	subcommands := command.Commands()
+	var subcommands []*cobra.Command
+	for _, c := range command.Commands() {
+		if !c.IsAvailableCommand() {
+			continue
+		}
+		subcommands = append(subcommands, c)
+	}
+
 	if len(subcommands) > 0 {
 		fmt.Fprint(w, "\n\nAvailable commands:\n")
 		for _, c := range subcommands {
-			if c.Hidden {
-				continue
-			}
 			fmt.Fprintf(w, "  %s\n", c.Name())
 		}
 		return nil
@@ -82,8 +88,10 @@ func isRootCmd(command *cobra.Command) bool {
 }
 
 func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
+	flags := command.Flags()
+
 	if isRootCmd(command) {
-		if versionVal, err := command.Flags().GetBool("version"); err == nil && versionVal {
+		if versionVal, err := flags.GetBool("version"); err == nil && versionVal {
 			fmt.Fprint(f.IOStreams.Out, command.Annotations["versionInfo"])
 			return
 		} else if err != nil {
@@ -95,8 +103,8 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
 
 	cs := f.IOStreams.ColorScheme()
 
-	if isRootCmd(command.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" {
-		nestedSuggestFunc(f.IOStreams.ErrOut, command, args[1])
+	if help, _ := flags.GetBool("help"); !help && !command.Runnable() && len(flags.Args()) > 0 {
+		nestedSuggestFunc(f.IOStreams.ErrOut, command, strings.Join(flags.Args(), " "))
 		hasFailed = true
 		return
 	}
@@ -144,14 +152,6 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
 		}
 		sort.Strings(helpTopics)
 		helpEntries = append(helpEntries, helpEntry{"HELP TOPICS", strings.Join(helpTopics, "\n")})
-
-		if exts := f.ExtensionManager.List(); len(exts) > 0 {
-			var names []string
-			for _, ext := range exts {
-				names = append(names, ext.Name())
-			}
-			helpEntries = append(helpEntries, helpEntry{"EXTENSION COMMANDS", strings.Join(names, "\n")})
-		}
 	}
 
 	flagUsages := command.LocalFlags().FlagUsages()
@@ -189,6 +189,27 @@ Read the manual at https://cli.github.com/manual`})
 	}
 }
 
+func authHelp() string {
+	if os.Getenv("GITHUB_ACTIONS") == "true" {
+		return heredoc.Doc(`
+			gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
+			  env:
+			    GH_TOKEN: ${{ github.token }}
+		`)
+	}
+
+	if os.Getenv("CI") != "" {
+		return heredoc.Doc(`
+			gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
+		`)
+	}
+
+	return heredoc.Doc(`
+		To get started with GitHub CLI, please run:  gh auth login
+		Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
+	`)
+}
+
 func findCommand(cmd *cobra.Command, name string) *cobra.Command {
 	for _, c := range cmd.Commands() {
 		if c.Name() == name {
diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go
index 8d18a4db1..5a79e8aeb 100644
--- a/pkg/cmd/root/root.go
+++ b/pkg/cmd/root/root.go
@@ -1,8 +1,10 @@
 package root
 
 import (
+	"fmt"
 	"net/http"
 	"os"
+	"strings"
 	"sync"
 
 	"github.com/MakeNowJust/heredoc"
@@ -10,6 +12,7 @@ import (
 	codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api"
 	actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions"
 	aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias"
+	"github.com/cli/cli/v2/pkg/cmd/alias/shared"
 	apiCmd "github.com/cli/cli/v2/pkg/cmd/api"
 	authCmd "github.com/cli/cli/v2/pkg/cmd/auth"
 	browseCmd "github.com/cli/cli/v2/pkg/cmd/browse"
@@ -36,15 +39,29 @@ import (
 	versionCmd "github.com/cli/cli/v2/pkg/cmd/version"
 	workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow"
 	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/google/shlex"
 	"github.com/spf13/cobra"
 )
 
-func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
+type AuthError struct {
+	err error
+}
+
+func (ae *AuthError) Error() string {
+	return ae.err.Error()
+}
+
+func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, error) {
+	io := f.IOStreams
+	cfg, err := f.Config()
+	if err != nil {
+		return nil, fmt.Errorf("failed to read configuration: %s\n", err)
+	}
+
 	cmd := &cobra.Command{
 		Use:   "gh <command> <subcommand> [flags]",
 		Short: "GitHub CLI",
 		Long:  `Work seamlessly with GitHub from the command line.`,
-
 		Example: heredoc.Doc(`
 			$ gh issue create
 			$ gh repo clone cli/cli
@@ -53,6 +70,14 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
 		Annotations: map[string]string{
 			"versionInfo": versionCmd.Format(version, buildDate),
 		},
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			// require that the user is authenticated before running most commands
+			if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
+				fmt.Fprint(io.ErrOut, authHelp())
+				return &AuthError{}
+			}
+			return nil
+		},
 	}
 
 	// cmd.SetOut(f.IOStreams.Out)    // can't use due to https://github.com/spf13/cobra/issues/1708
@@ -85,6 +110,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
 		ID:    "actions",
 		Title: "GitHub Actions commands",
 	})
+	cmd.AddGroup(&cobra.Group{
+		ID:    "extension",
+		Title: "Extension commands",
+	})
 
 	// Child commands
 	cmd.AddCommand(versionCmd.NewCmdVersion(f, version, buildDate))
@@ -136,6 +165,51 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
 		}
 	}
 
+	// Aliases
+	aliases := cfg.Aliases()
+	validAliasName := shared.ValidAliasNameFunc(cmd)
+	validAliasExpansion := shared.ValidAliasExpansionFunc(cmd)
+	for k, v := range aliases.All() {
+		aliasName := k
+		aliasValue := v
+		if validAliasName(aliasName) && validAliasExpansion(aliasValue) {
+			split, _ := shlex.Split(aliasName)
+			parentCmd, parentArgs, _ := cmd.Find(split)
+			if !parentCmd.ContainsGroup("alias") {
+				parentCmd.AddGroup(&cobra.Group{
+					ID:    "alias",
+					Title: "Alias commands",
+				})
+			}
+			if strings.HasPrefix(aliasValue, "!") {
+				shellAliasCmd := NewCmdShellAlias(io, parentArgs[0], aliasValue)
+				parentCmd.AddCommand(shellAliasCmd)
+				parentCmd.ValidArgs = append(parentCmd.ValidArgs, fmt.Sprintf("%s\tShell alias", aliasName))
+			} else {
+				aliasCmd := NewCmdAlias(io, parentArgs[0], aliasValue)
+				split, _ := shlex.Split(aliasValue)
+				child, _, _ := cmd.Find(split)
+				aliasCmd.SetUsageFunc(func(_ *cobra.Command) error {
+					return rootUsageFunc(f.IOStreams.ErrOut, child)
+				})
+				aliasCmd.SetHelpFunc(func(_ *cobra.Command, args []string) {
+					rootHelpFunc(f, child, args)
+				})
+				parentCmd.AddCommand(aliasCmd)
+				parentCmd.ValidArgs = append(parentCmd.ValidArgs, fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue))
+			}
+		}
+	}
+
+	// Extensions
+	em := f.ExtensionManager
+	for _, e := range em.List() {
+		extension := e
+		extensionCmd := NewCmdExtension(io, em, e)
+		cmd.AddCommand(extensionCmd)
+		cmd.ValidArgs = append(cmd.ValidArgs, fmt.Sprintf("%s\t%s", extension.Name(), extensionCmd.Short))
+	}
+
 	cmdutil.DisableAuthCheck(cmd)
 
 	// The reference command produces paged output that displays information on every other command.
@@ -145,7 +219,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
 	// is special.
 	referenceCmd.Long = stringifyReference(cmd)
 	referenceCmd.SetHelpFunc(longPager(f.IOStreams))
-	return cmd
+	return cmd, nil
 }
 
 func bareHTTPClient(f *cmdutil.Factory, version string) func() (*http.Client, error) {
diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go
index ca4521b1d..e5f77e016 100644
--- a/pkg/cmd/run/cancel/cancel.go
+++ b/pkg/cmd/run/cancel/cancel.go
@@ -17,6 +17,7 @@ type CancelOptions struct {
 	HttpClient func() (*http.Client, error)
 	IO         *iostreams.IOStreams
 	BaseRepo   func() (ghrepo.Interface, error)
+	Prompter   shared.Prompter
 
 	Prompt bool
 
@@ -27,6 +28,7 @@ func NewCmdCancel(f *cmdutil.Factory, runF func(*CancelOptions) error) *cobra.Co
 	opts := &CancelOptions{
 		IO:         f.IOStreams,
 		HttpClient: f.HttpClient,
+		Prompter:   f.Prompter,
 	}
 
 	cmd := &cobra.Command{
@@ -83,11 +85,10 @@ func runCancel(opts *CancelOptions) error {
 		if len(runs) == 0 {
 			return fmt.Errorf("found no in progress runs to cancel")
 		}
-		runID, err = shared.PromptForRun(cs, runs)
-		if err != nil {
+		if runID, err = shared.SelectRun(opts.Prompter, cs, runs); err != nil {
 			return err
 		}
-		// TODO silly stopgap until dust settles and PromptForRun can just return a run
+		// TODO silly stopgap until dust settles and SelectRun can just return a run
 		for _, r := range runs {
 			if fmt.Sprintf("%d", r.ID) == runID {
 				run = &r
diff --git a/pkg/cmd/run/download/download.go b/pkg/cmd/run/download/download.go
index 25eec5d62..993e16e52 100644
--- a/pkg/cmd/run/download/download.go
+++ b/pkg/cmd/run/download/download.go
@@ -5,12 +5,10 @@ import (
 	"fmt"
 	"path/filepath"
 
-	"github.com/AlecAivazis/survey/v2"
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/pkg/cmd/run/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/cli/cli/v2/pkg/set"
 	"github.com/spf13/cobra"
 )
@@ -18,7 +16,7 @@ import (
 type DownloadOptions struct {
 	IO       *iostreams.IOStreams
 	Platform platform
-	Prompter prompter
+	Prompter iprompter
 
 	DoPrompt       bool
 	RunID          string
@@ -31,13 +29,14 @@ type platform interface {
 	List(runID string) ([]shared.Artifact, error)
 	Download(url string, dir string) error
 }
-type prompter interface {
-	Prompt(message string, options []string, result interface{}) error
+type iprompter interface {
+	MultiSelect(string, []string, []string) ([]int, error)
 }
 
 func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
 	opts := &DownloadOptions{
-		IO: f.IOStreams,
+		IO:       f.IOStreams,
+		Prompter: f.Prompter,
 	}
 
 	cmd := &cobra.Command{
@@ -85,7 +84,6 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
 				client: httpClient,
 				repo:   baseRepo,
 			}
-			opts.Prompter = &surveyPrompter{}
 
 			if runF != nil {
 				return runF(opts)
@@ -133,10 +131,14 @@ func runDownload(opts *DownloadOptions) error {
 		if len(options) > 10 {
 			options = options[:10]
 		}
-		err := opts.Prompter.Prompt("Select artifacts to download:", options, &wantNames)
-		if err != nil {
+		var selected []int
+		if selected, err = opts.Prompter.MultiSelect("Select artifacts to download:", nil, options); err != nil {
 			return err
 		}
+		wantNames = []string{}
+		for _, x := range selected {
+			wantNames = append(wantNames, options[x])
+		}
 		if len(wantNames) == 0 {
 			return errors.New("no artifacts selected")
 		}
@@ -194,13 +196,3 @@ func matchAnyPattern(patterns []string, name string) bool {
 	}
 	return false
 }
-
-type surveyPrompter struct{}
-
-func (sp *surveyPrompter) Prompt(message string, options []string, result interface{}) error {
-	//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
-	return prompt.SurveyAskOne(&survey.MultiSelect{
-		Message: message,
-		Options: options,
-	}, result)
-}
diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go
index 2522e632c..b640f41f7 100644
--- a/pkg/cmd/run/rerun/rerun.go
+++ b/pkg/cmd/run/rerun/rerun.go
@@ -20,6 +20,7 @@ type RerunOptions struct {
 	HttpClient func() (*http.Client, error)
 	IO         *iostreams.IOStreams
 	BaseRepo   func() (ghrepo.Interface, error)
+	Prompter   shared.Prompter
 
 	RunID      string
 	OnlyFailed bool
@@ -32,6 +33,7 @@ type RerunOptions struct {
 func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Command {
 	opts := &RerunOptions{
 		IO:         f.IOStreams,
+		Prompter:   f.Prompter,
 		HttpClient: f.HttpClient,
 	}
 
@@ -114,8 +116,7 @@ func runRerun(opts *RerunOptions) error {
 		if len(runs) == 0 {
 			return errors.New("no recent runs have failed; please specify a specific `<run-id>`")
 		}
-		runID, err = shared.PromptForRun(cs, runs)
-		if err != nil {
+		if runID, err = shared.SelectRun(opts.Prompter, cs, runs); err != nil {
 			return err
 		}
 	}
diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go
index 70d44df95..a755ce93c 100644
--- a/pkg/cmd/run/shared/shared.go
+++ b/pkg/cmd/run/shared/shared.go
@@ -9,15 +9,16 @@ import (
 	"strings"
 	"time"
 
-	"github.com/AlecAivazis/survey/v2"
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/ghrepo"
-	"github.com/cli/cli/v2/internal/prompter"
 	workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 )
 
+type Prompter interface {
+	Select(string, string, []string) (int, error)
+}
+
 const (
 	// Run statuses
 	Queued     Status = "queued"
@@ -451,7 +452,7 @@ func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, erro
 }
 
 // SelectRun prompts the user to select a run from a list of runs by using the recommended prompter interface
-func SelectRun(p prompter.Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) {
+func SelectRun(p Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) {
 	now := time.Now()
 
 	candidates := []string{}
@@ -472,36 +473,6 @@ func SelectRun(p prompter.Prompter, cs *iostreams.ColorScheme, runs []Run) (stri
 	return fmt.Sprintf("%d", runs[selected].ID), nil
 }
 
-// TODO: this should be deprecated in favor of SelectRun
-func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) {
-	var selected int
-	now := time.Now()
-
-	candidates := []string{}
-
-	for _, run := range runs {
-		symbol, _ := Symbol(cs, run.Status, run.Conclusion)
-		candidates = append(candidates,
-			// TODO truncate commit message, long ones look terrible
-			fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
-	}
-
-	// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
-	// become contiguous
-	//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
-	err := prompt.SurveyAskOne(&survey.Select{
-		Message:  "Select a workflow run",
-		Options:  candidates,
-		PageSize: 10,
-	}, &selected)
-
-	if err != nil {
-		return "", err
-	}
-
-	return fmt.Sprintf("%d", runs[selected].ID), nil
-}
-
 func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) {
 	var result Run
 
diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go
index 5cb265b55..87ed9b4fa 100644
--- a/pkg/cmd/run/view/view.go
+++ b/pkg/cmd/run/view/view.go
@@ -14,7 +14,6 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/AlecAivazis/survey/v2"
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/browser"
@@ -24,7 +23,6 @@ import (
 	"github.com/cli/cli/v2/pkg/cmd/run/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/spf13/cobra"
 )
 
@@ -65,6 +63,7 @@ type ViewOptions struct {
 	IO          *iostreams.IOStreams
 	BaseRepo    func() (ghrepo.Interface, error)
 	Browser     browser.Browser
+	Prompter    shared.Prompter
 	RunLogCache runLogCache
 
 	RunID      string
@@ -86,6 +85,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
 	opts := &ViewOptions{
 		IO:          f.IOStreams,
 		HttpClient:  f.HttpClient,
+		Prompter:    f.Prompter,
 		Now:         time.Now,
 		Browser:     f.Browser,
 		RunLogCache: rlc{},
@@ -205,7 +205,7 @@ func runView(opts *ViewOptions) error {
 		if err != nil {
 			return fmt.Errorf("failed to get runs: %w", err)
 		}
-		runID, err = shared.PromptForRun(cs, runs.WorkflowRuns)
+		runID, err = shared.SelectRun(opts.Prompter, cs, runs.WorkflowRuns)
 		if err != nil {
 			return err
 		}
@@ -228,7 +228,7 @@ func runView(opts *ViewOptions) error {
 	}
 
 	if opts.Prompt && len(jobs) > 1 {
-		selectedJob, err = promptForJob(cs, jobs)
+		selectedJob, err = promptForJob(opts.Prompter, cs, jobs)
 		if err != nil {
 			return err
 		}
@@ -459,20 +459,14 @@ func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface
 	return cache.Open(filepath)
 }
 
-func promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, error) {
+func promptForJob(prompter shared.Prompter, cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, error) {
 	candidates := []string{"View all jobs in this run"}
 	for _, job := range jobs {
 		symbol, _ := shared.Symbol(cs, job.Status, job.Conclusion)
 		candidates = append(candidates, fmt.Sprintf("%s %s", symbol, job.Name))
 	}
 
-	var selected int
-	//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
-	err := prompt.SurveyAskOne(&survey.Select{
-		Message:  "View a specific job in this run?",
-		Options:  candidates,
-		PageSize: 12,
-	}, &selected)
+	selected, err := prompter.Select("View a specific job in this run?", "", candidates)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go
index 05b207693..090737557 100644
--- a/pkg/cmd/run/watch/watch.go
+++ b/pkg/cmd/run/watch/watch.go
@@ -23,6 +23,7 @@ type WatchOptions struct {
 	IO         *iostreams.IOStreams
 	HttpClient func() (*http.Client, error)
 	BaseRepo   func() (ghrepo.Interface, error)
+	Prompter   shared.Prompter
 
 	RunID      string
 	Interval   int
@@ -37,6 +38,7 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm
 	opts := &WatchOptions{
 		IO:         f.IOStreams,
 		HttpClient: f.HttpClient,
+		Prompter:   f.Prompter,
 		Now:        time.Now,
 	}
 
@@ -102,11 +104,10 @@ func watchRun(opts *WatchOptions) error {
 		if len(runs) == 0 {
 			return fmt.Errorf("found no in progress runs to watch")
 		}
-		runID, err = shared.PromptForRun(cs, runs)
-		if err != nil {
+		if runID, err = shared.SelectRun(opts.Prompter, cs, runs); err != nil {
 			return err
 		}
-		// TODO silly stopgap until dust settles and PromptForRun can just return a run
+		// TODO silly stopgap until dust settles and SelectRun can just return a run
 		for _, r := range runs {
 			if fmt.Sprintf("%d", r.ID) == runID {
 				run = &r


Test patch:
diff --git a/internal/prompter/test.go b/internal/prompter/test.go
index a2d0b83ee..c376e9c83 100644
--- a/internal/prompter/test.go
+++ b/internal/prompter/test.go
@@ -50,7 +50,7 @@ type ConfirmStub struct {
 type MultiSelectStub struct {
 	Prompt       string
 	ExpectedOpts []string
-	Fn           func(string, string, []string) ([]string, error)
+	Fn           func(string, []string, []string) ([]int, error)
 }
 
 type InputHostnameStub struct {
@@ -84,8 +84,6 @@ type MockPrompter struct {
 	ConfirmDeletionStubs []ConfirmDeletionStub
 }
 
-// TODO thread safety
-
 func NewMockPrompter(t *testing.T) *MockPrompter {
 	m := &MockPrompter{
 		t:                    t,
@@ -120,17 +118,17 @@ func NewMockPrompter(t *testing.T) *MockPrompter {
 		return s.Fn(p, d, opts)
 	}
 
-	m.MultiSelectFunc = func(p, d string, opts []string) ([]string, error) {
+	m.MultiSelectFunc = func(p string, d, opts []string) ([]int, error) {
 		var s MultiSelectStub
-		if len(m.SelectStubs) > 0 {
+		if len(m.MultiSelectStubs) > 0 {
 			s = m.MultiSelectStubs[0]
-			m.SelectStubs = m.SelectStubs[1:len(m.SelectStubs)]
+			m.MultiSelectStubs = m.MultiSelectStubs[1:len(m.MultiSelectStubs)]
 		} else {
-			return []string{}, NoSuchPromptErr(p)
+			return []int{}, NoSuchPromptErr(p)
 		}
 
 		if s.Prompt != p {
-			return []string{}, NoSuchPromptErr(p)
+			return []int{}, NoSuchPromptErr(p)
 		}
 
 		AssertOptions(m.t, s.ExpectedOpts, opts)
@@ -240,7 +238,7 @@ func (m *MockPrompter) RegisterSelect(prompt string, opts []string, stub func(_,
 		Fn:           stub})
 }
 
-func (m *MockPrompter) RegisterMultiSelect(prompt string, opts []string, stub func(_, _ string, _ []string) ([]string, error)) {
+func (m *MockPrompter) RegisterMultiSelect(prompt string, d, opts []string, stub func(_ string, _, _ []string) ([]int, error)) {
 	m.MultiSelectStubs = append(m.MultiSelectStubs, MultiSelectStub{
 		Prompt:       prompt,
 		ExpectedOpts: opts,
diff --git a/pkg/cmd/alias/expand/expand_test.go b/pkg/cmd/alias/expand/expand_test.go
deleted file mode 100644
index 33af4b073..000000000
--- a/pkg/cmd/alias/expand/expand_test.go
+++ /dev/null
@@ -1,185 +0,0 @@
-package expand
-
-import (
-	"errors"
-	"reflect"
-	"testing"
-
-	"github.com/MakeNowJust/heredoc"
-	"github.com/cli/cli/v2/internal/config"
-)
-
-func TestExpandAlias(t *testing.T) {
-	findShFunc := func() (string, error) {
-		return "/usr/bin/sh", nil
-	}
-
-	cfg := config.NewFromString(heredoc.Doc(`
-		aliases:
-		  co: pr checkout
-		  il: issue list --author="$1" --label="$2"
-		  ia: issue list --author="$1" --assignee="$1"
-	`))
-
-	type args struct {
-		config config.Config
-		argv   []string
-	}
-	tests := []struct {
-		name         string
-		args         args
-		wantExpanded []string
-		wantIsShell  bool
-		wantErr      error
-	}{
-		{
-			name: "no arguments",
-			args: args{
-				config: cfg,
-				argv:   []string{},
-			},
-			wantExpanded: []string(nil),
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-		{
-			name: "too few arguments",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh"},
-			},
-			wantExpanded: []string(nil),
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-		{
-			name: "no expansion",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "pr", "status"},
-			},
-			wantExpanded: []string{"pr", "status"},
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-		{
-			name: "simple expansion",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "co"},
-			},
-			wantExpanded: []string{"pr", "checkout"},
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-		{
-			name: "adding arguments after expansion",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "co", "123"},
-			},
-			wantExpanded: []string{"pr", "checkout", "123"},
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-		{
-			name: "not enough arguments for expansion",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "il"},
-			},
-			wantExpanded: []string{},
-			wantIsShell:  false,
-			wantErr:      errors.New(`not enough arguments for alias: issue list --author="$1" --label="$2"`),
-		},
-		{
-			name: "not enough arguments for expansion 2",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "il", "vilmibm"},
-			},
-			wantExpanded: []string{},
-			wantIsShell:  false,
-			wantErr:      errors.New(`not enough arguments for alias: issue list --author="vilmibm" --label="$2"`),
-		},
-		{
-			name: "satisfy expansion arguments",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "il", "vilmibm", "help wanted"},
-			},
-			wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"},
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-		{
-			name: "mixed positional and non-positional arguments",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "il", "vilmibm", "epic", "-R", "monalisa/testing"},
-			},
-			wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"},
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-		{
-			name: "dollar in expansion",
-			args: args{
-				config: cfg,
-				argv:   []string{"gh", "ia", "$coolmoney$"},
-			},
-			wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"},
-			wantIsShell:  false,
-			wantErr:      nil,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			gotExpanded, gotIsShell, err := ExpandAlias(tt.args.config, tt.args.argv, findShFunc)
-			if tt.wantErr != nil {
-				if err == nil {
-					t.Fatal("expected error")
-				}
-				if tt.wantErr.Error() != err.Error() {
-					t.Fatalf("expected error %q, got %q", tt.wantErr, err)
-				}
-				return
-			}
-			if err != nil {
-				t.Fatalf("got error: %v", err)
-			}
-			if !reflect.DeepEqual(gotExpanded, tt.wantExpanded) {
-				t.Errorf("ExpandAlias() gotExpanded = %v, want %v", gotExpanded, tt.wantExpanded)
-			}
-			if gotIsShell != tt.wantIsShell {
-				t.Errorf("ExpandAlias() gotIsShell = %v, want %v", gotIsShell, tt.wantIsShell)
-			}
-		})
-	}
-}
-
-// cfg := `---
-// aliases:
-//   co: pr checkout
-//   il: issue list --author="$1" --label="$2"
-//   ia: issue list --author="$1" --assignee="$1"
-// `
-// 	initBlankContext(cfg, "OWNER/REPO", "trunk")
-// 	for _, c := range []struct {
-// 		Args         string
-// 		ExpectedArgs []string
-// 		Err          string
-// 	}{
-// 		{"gh co", []string{"pr", "checkout"}, ""},
-// 		{"gh il", nil, `not enough arguments for alias: issue list --author="$1" --label="$2"`},
-// 		{"gh il vilmibm", nil, `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`},
-// 		{"gh co 123", []string{"pr", "checkout", "123"}, ""},
-// 		{"gh il vilmibm epic", []string{"issue", "list", `--author=vilmibm`, `--label=epic`}, ""},
-// 		{"gh ia vilmibm", []string{"issue", "list", `--author=vilmibm`, `--assignee=vilmibm`}, ""},
-// 		{"gh ia $coolmoney$", []string{"issue", "list", `--author=$coolmoney$`, `--assignee=$coolmoney$`}, ""},
-// 		{"gh pr status", []string{"pr", "status"}, ""},
-// 		{"gh il vilmibm epic -R vilmibm/testing", []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "vilmibm/testing"}, ""},
-// 		{"gh dne", []string{"dne"}, ""},
-// 		{"gh", []string{}, ""},
-// 		{"", []string{}, ""},
-// 	} {
diff --git a/pkg/cmd/alias/imports/import_test.go b/pkg/cmd/alias/imports/import_test.go
index 8837a52db..0013429f6 100644
--- a/pkg/cmd/alias/imports/import_test.go
+++ b/pkg/cmd/alias/imports/import_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/pkg/cmd/alias/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
-	"github.com/cli/cli/v2/pkg/extensions"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/google/shlex"
 	"github.com/spf13/cobra"
@@ -257,9 +256,9 @@ func TestImportRun(t *testing.T) {
 			wantStderr: strings.Join(
 				[]string{
 					importFileMsg,
-					"X Could not import alias api: already a gh command",
-					"X Could not import alias issue: already a gh command",
-					"X Could not import alias pr: already a gh command\n\n",
+					"X Could not import alias api: already a gh command, extension, or alias",
+					"X Could not import alias issue: already a gh command, extension, or alias",
+					"X Could not import alias pr: already a gh command, extension, or alias\n\n",
 				},
 				"\n",
 			),
@@ -277,8 +276,8 @@ func TestImportRun(t *testing.T) {
 			wantStderr: strings.Join(
 				[]string{
 					importFileMsg,
-					"X Could not import alias alias1: expansion does not correspond to a gh command",
-					"X Could not import alias alias2: expansion does not correspond to a gh command\n\n",
+					"X Could not import alias alias1: expansion does not correspond to a gh command, extension, or alias",
+					"X Could not import alias alias2: expansion does not correspond to a gh command, extension, or alias\n\n",
 				},
 				"\n",
 			),
@@ -304,16 +303,6 @@ func TestImportRun(t *testing.T) {
 				return cfg, nil
 			}
 
-			// Create fake command factory for testing.
-			f := &cmdutil.Factory{
-				IOStreams: ios,
-				ExtensionManager: &extensions.ExtensionManagerMock{
-					ListFunc: func() []extensions.Extension {
-						return []extensions.Extension{}
-					},
-				},
-			}
-
 			// Create fake command structure for testing.
 			rootCmd := &cobra.Command{}
 			prCmd := &cobra.Command{Use: "pr"}
@@ -327,7 +316,8 @@ func TestImportRun(t *testing.T) {
 			apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
 			rootCmd.AddCommand(apiCmd)
 
-			tt.opts.existingCommand = shared.ExistingCommandFunc(f, rootCmd)
+			tt.opts.validAliasName = shared.ValidAliasNameFunc(rootCmd)
+			tt.opts.validAliasExpansion = shared.ValidAliasExpansionFunc(rootCmd)
 
 			if tt.stdin != "" {
 				stdin.WriteString(tt.stdin)
diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go
index bec03cba4..54fcabf5b 100644
--- a/pkg/cmd/alias/set/set_test.go
+++ b/pkg/cmd/alias/set/set_test.go
@@ -73,7 +73,7 @@ func TestAliasSet_gh_command(t *testing.T) {
 	cfg := config.NewFromString(``)
 
 	_, err := runCommand(cfg, true, "pr 'pr status'", "")
-	assert.EqualError(t, err, `could not create alias: "pr" is already a gh command`)
+	assert.EqualError(t, err, `could not create alias: "pr" is already a gh command, extension, or alias`)
 }
 
 func TestAliasSet_empty_aliases(t *testing.T) {
@@ -231,7 +231,7 @@ func TestAliasSet_invalid_command(t *testing.T) {
 	cfg := config.NewFromString(``)
 
 	_, err := runCommand(cfg, true, "co 'pe checkout'", "")
-	assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command")
+	assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command, extension, or alias")
 }
 
 func TestShellAlias_flag(t *testing.T) {
diff --git a/pkg/cmd/alias/shared/validations_test.go b/pkg/cmd/alias/shared/validations_test.go
index faa0e497d..72270a608 100644
--- a/pkg/cmd/alias/shared/validations_test.go
+++ b/pkg/cmd/alias/shared/validations_test.go
@@ -3,22 +3,11 @@ package shared
 import (
 	"testing"
 
-	"github.com/cli/cli/v2/pkg/cmdutil"
-	"github.com/cli/cli/v2/pkg/extensions"
 	"github.com/spf13/cobra"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestExistingCommandFunc(t *testing.T) {
-	// Create fake command factory for testing.
-	factory := &cmdutil.Factory{
-		ExtensionManager: &extensions.ExtensionManagerMock{
-			ListFunc: func() []extensions.Extension {
-				return []extensions.Extension{}
-			},
-		},
-	}
-
+func TestValidAliasNameFunc(t *testing.T) {
 	// Create fake command structure for testing.
 	issueCmd := &cobra.Command{Use: "issue"}
 	prCmd := &cobra.Command{Use: "pr"}
@@ -28,13 +17,38 @@ func TestExistingCommandFunc(t *testing.T) {
 	cmd.AddCommand(prCmd)
 	cmd.AddCommand(issueCmd)
 
-	f := ExistingCommandFunc(factory, cmd)
+	f := ValidAliasNameFunc(cmd)
 
-	assert.True(t, f("pr"))
-	assert.True(t, f("pr checkout"))
-	assert.True(t, f("issue"))
+	assert.False(t, f("pr"))
+	assert.False(t, f("pr checkout"))
+	assert.False(t, f("issue"))
+	assert.False(t, f("repo list"))
+
+	assert.True(t, f("ps"))
+	assert.True(t, f("checkout"))
+	assert.True(t, f("issue erase"))
+	assert.True(t, f("pr erase"))
+	assert.True(t, f("pr checkout branch"))
+}
+
+func TestValidAliasExpansionFunc(t *testing.T) {
+	// Create fake command structure for testing.
+	issueCmd := &cobra.Command{Use: "issue"}
+	prCmd := &cobra.Command{Use: "pr"}
+	prCmd.AddCommand(&cobra.Command{Use: "checkout"})
+
+	cmd := &cobra.Command{}
+	cmd.AddCommand(prCmd)
+	cmd.AddCommand(issueCmd)
+
+	f := ValidAliasExpansionFunc(cmd)
 
 	assert.False(t, f("ps"))
 	assert.False(t, f("checkout"))
 	assert.False(t, f("repo list"))
+
+	assert.True(t, f("!git branch --show-current"))
+	assert.True(t, f("pr"))
+	assert.True(t, f("pr checkout"))
+	assert.True(t, f("issue"))
 }
diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go
index ebc4716de..6080e8483 100644
--- a/pkg/cmd/extension/command_test.go
+++ b/pkg/cmd/extension/command_test.go
@@ -974,7 +974,7 @@ func Test_checkValidExtension(t *testing.T) {
 				manager: m,
 				extName: "gh-auth",
 			},
-			wantError: "\"auth\" matches the name of a built-in command",
+			wantError: "\"auth\" matches the name of a built-in command or alias",
 		},
 		{
 			name: "clashes with an installed extension",
diff --git a/pkg/cmd/root/alias_test.go b/pkg/cmd/root/alias_test.go
new file mode 100644
index 000000000..4a2d3c13e
--- /dev/null
+++ b/pkg/cmd/root/alias_test.go
@@ -0,0 +1,123 @@
+package root
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExpandAlias(t *testing.T) {
+	tests := []struct {
+		name         string
+		expansion    string
+		args         []string
+		wantExpanded []string
+		wantErr      string
+	}{
+		{
+			name:         "no expansion",
+			expansion:    "pr status",
+			args:         []string{},
+			wantExpanded: []string{"pr", "status"},
+		},
+		{
+			name:         "adding arguments after expansion",
+			expansion:    "pr checkout",
+			args:         []string{"123"},
+			wantExpanded: []string{"pr", "checkout", "123"},
+		},
+		{
+			name:      "not enough arguments for expansion",
+			expansion: `issue list --author="$1" --label="$2"`,
+			args:      []string{},
+			wantErr:   `not enough arguments for alias: issue list --author="$1" --label="$2"`,
+		},
+		{
+			name:      "not enough arguments for expansion 2",
+			expansion: `issue list --author="$1" --label="$2"`,
+			args:      []string{"vilmibm"},
+			wantErr:   `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`,
+		},
+		{
+			name:         "satisfy expansion arguments",
+			expansion:    `issue list --author="$1" --label="$2"`,
+			args:         []string{"vilmibm", "help wanted"},
+			wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"},
+		},
+		{
+			name:         "mixed positional and non-positional arguments",
+			expansion:    `issue list --author="$1" --label="$2"`,
+			args:         []string{"vilmibm", "epic", "-R", "monalisa/testing"},
+			wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"},
+		},
+		{
+			name:         "dollar in expansion",
+			expansion:    `issue list --author="$1" --assignee="$1"`,
+			args:         []string{"$coolmoney$"},
+			wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotExpanded, err := expandAlias(tt.expansion, tt.args)
+			if tt.wantErr != "" {
+				assert.Nil(t, gotExpanded)
+				assert.EqualError(t, err, tt.wantErr)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tt.wantExpanded, gotExpanded)
+		})
+	}
+}
+
+func TestExpandShellAlias(t *testing.T) {
+	findShFunc := func() (string, error) {
+		return "/usr/bin/sh", nil
+	}
+	tests := []struct {
+		name         string
+		expansion    string
+		args         []string
+		findSh       func() (string, error)
+		wantExpanded []string
+		wantErr      string
+	}{
+		{
+			name:         "simple expansion",
+			expansion:    "!git branch --show-current",
+			args:         []string{},
+			findSh:       findShFunc,
+			wantExpanded: []string{"/usr/bin/sh", "-c", "git branch --show-current"},
+		},
+		{
+			name:         "adding arguments after expansion",
+			expansion:    "!git branch checkout",
+			args:         []string{"123"},
+			findSh:       findShFunc,
+			wantExpanded: []string{"/usr/bin/sh", "-c", "git branch checkout", "--", "123"},
+		},
+		{
+			name:      "unable to find sh",
+			expansion: "!git branch --show-current",
+			args:      []string{},
+			findSh: func() (string, error) {
+				return "", errors.New("unable to locate sh")
+			},
+			wantErr: "unable to locate sh",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotExpanded, err := expandShellAlias(tt.expansion, tt.args, tt.findSh)
+			if tt.wantErr != "" {
+				assert.Nil(t, gotExpanded)
+				assert.EqualError(t, err, tt.wantErr)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tt.wantExpanded, gotExpanded)
+		})
+	}
+}
diff --git a/pkg/cmd/run/cancel/cancel_test.go b/pkg/cmd/run/cancel/cancel_test.go
index bcde453c2..fdfedfa0c 100644
--- a/pkg/cmd/run/cancel/cancel_test.go
+++ b/pkg/cmd/run/cancel/cancel_test.go
@@ -7,12 +7,12 @@ import (
 	"testing"
 
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/run/shared"
 	workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 )
@@ -86,13 +86,13 @@ func TestRunCancel(t *testing.T) {
 	inProgressRun := shared.TestRun(1234, shared.InProgress, "")
 	completedRun := shared.TestRun(4567, shared.Completed, shared.Failure)
 	tests := []struct {
-		name      string
-		httpStubs func(*httpmock.Registry)
-		askStubs  func(*prompt.AskStubber)
-		opts      *CancelOptions
-		wantErr   bool
-		wantOut   string
-		errMsg    string
+		name        string
+		httpStubs   func(*httpmock.Registry)
+		promptStubs func(*prompter.MockPrompter)
+		opts        *CancelOptions
+		wantErr     bool
+		wantOut     string
+		errMsg      string
 	}{
 		{
 			name: "cancel run",
@@ -194,9 +194,12 @@ func TestRunCancel(t *testing.T) {
 					httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"),
 					httpmock.StatusStringResponse(202, "{}"))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(0)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"* cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "* cool commit, CI (trunk) Feb 23, 2021")
+					})
 			},
 			wantOut: "✓ Request to cancel workflow submitted.\n",
 		},
@@ -217,11 +220,10 @@ func TestRunCancel(t *testing.T) {
 			return ghrepo.FromFullName("OWNER/REPO")
 		}
 
-		//nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber
-		as, teardown := prompt.InitAskStubber()
-		defer teardown()
-		if tt.askStubs != nil {
-			tt.askStubs(as)
+		pm := prompter.NewMockPrompter(t)
+		tt.opts.Prompter = pm
+		if tt.promptStubs != nil {
+			tt.promptStubs(pm)
 		}
 
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/cmd/run/download/download_test.go b/pkg/cmd/run/download/download_test.go
index 10c7bbe3c..3c1c8f2d8 100644
--- a/pkg/cmd/run/download/download_test.go
+++ b/pkg/cmd/run/download/download_test.go
@@ -8,6 +8,7 @@ import (
 	"testing"
 
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/run/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -144,11 +145,11 @@ func Test_NewCmdDownload(t *testing.T) {
 
 func Test_runDownload(t *testing.T) {
 	tests := []struct {
-		name       string
-		opts       DownloadOptions
-		mockAPI    func(*mockPlatform)
-		mockPrompt func(*mockPrompter)
-		wantErr    string
+		name        string
+		opts        DownloadOptions
+		mockAPI     func(*mockPlatform)
+		promptStubs func(*prompter.MockPrompter)
+		wantErr     string
 	}{
 		{
 			name: "download non-expired",
@@ -281,13 +282,11 @@ func Test_runDownload(t *testing.T) {
 				}, nil)
 				p.On("Download", "http://download.com/artifact2.zip", ".").Return(nil)
 			},
-			mockPrompt: func(p *mockPrompter) {
-				p.On("Prompt", "Select artifacts to download:", []string{"artifact-1", "artifact-2"}, mock.AnythingOfType("*[]string")).
-					Run(func(args mock.Arguments) {
-						result := args.Get(2).(*[]string)
-						*result = []string{"artifact-2"}
-					}).
-					Return(nil)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterMultiSelect("Select artifacts to download:", nil, []string{"artifact-1", "artifact-2"},
+					func(_ string, _, opts []string) ([]int, error) {
+						return []int{1}, nil
+					})
 			},
 		},
 	}
@@ -297,7 +296,12 @@ func Test_runDownload(t *testing.T) {
 			ios, _, stdout, stderr := iostreams.Test()
 			opts.IO = ios
 			opts.Platform = newMockPlatform(t, tt.mockAPI)
-			opts.Prompter = newMockPrompter(t, tt.mockPrompt)
+
+			pm := prompter.NewMockPrompter(t)
+			opts.Prompter = pm
+			if tt.promptStubs != nil {
+				tt.promptStubs(pm)
+			}
 
 			err := runDownload(opts)
 			if tt.wantErr != "" {
@@ -337,24 +341,3 @@ func (p *mockPlatform) Download(url string, dir string) error {
 	args := p.Called(url, dir)
 	return args.Error(0)
 }
-
-type mockPrompter struct {
-	mock.Mock
-}
-
-func newMockPrompter(t *testing.T, config func(*mockPrompter)) *mockPrompter {
-	m := &mockPrompter{}
-	m.Test(t)
-	t.Cleanup(func() {
-		m.AssertExpectations(t)
-	})
-	if config != nil {
-		config(m)
-	}
-	return m
-}
-
-func (p *mockPrompter) Prompt(msg string, opts []string, res interface{}) error {
-	args := p.Called(msg, opts, res)
-	return args.Error(0)
-}
diff --git a/pkg/cmd/run/rerun/rerun_test.go b/pkg/cmd/run/rerun/rerun_test.go
index 730952f1f..991ab5f3a 100644
--- a/pkg/cmd/run/rerun/rerun_test.go
+++ b/pkg/cmd/run/rerun/rerun_test.go
@@ -8,12 +8,12 @@ import (
 	"testing"
 
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/run/shared"
 	workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 )
@@ -161,15 +161,15 @@ func TestNewCmdRerun(t *testing.T) {
 
 func TestRerun(t *testing.T) {
 	tests := []struct {
-		name      string
-		httpStubs func(*httpmock.Registry)
-		askStubs  func(*prompt.AskStubber)
-		opts      *RerunOptions
-		tty       bool
-		wantErr   bool
-		errOut    string
-		wantOut   string
-		wantDebug bool
+		name        string
+		httpStubs   func(*httpmock.Registry)
+		promptStubs func(*prompter.MockPrompter)
+		opts        *RerunOptions
+		tty         bool
+		wantErr     bool
+		errOut      string
+		wantOut     string
+		wantDebug   bool
 	}{
 		{
 			name: "arg",
@@ -336,9 +336,12 @@ func TestRerun(t *testing.T) {
 					httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun"),
 					httpmock.StringResponse("{}"))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 2, nil
+					})
 			},
 			wantOut: "✓ Requested rerun of run 1234\n",
 		},
@@ -408,11 +411,10 @@ func TestRerun(t *testing.T) {
 			return ghrepo.FromFullName("OWNER/REPO")
 		}
 
-		//nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber
-		as, teardown := prompt.InitAskStubber()
-		defer teardown()
-		if tt.askStubs != nil {
-			tt.askStubs(as)
+		pm := prompter.NewMockPrompter(t)
+		tt.opts.Prompter = pm
+		if tt.promptStubs != nil {
+			tt.promptStubs(pm)
 		}
 
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go
index 99a9ef30e..be9e0554d 100644
--- a/pkg/cmd/run/view/view_test.go
+++ b/pkg/cmd/run/view/view_test.go
@@ -12,12 +12,12 @@ import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/browser"
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/run/shared"
 	workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 )
@@ -169,15 +169,15 @@ func TestNewCmdView(t *testing.T) {
 
 func TestViewRun(t *testing.T) {
 	tests := []struct {
-		name       string
-		httpStubs  func(*httpmock.Registry)
-		askStubs   func(*prompt.AskStubber)
-		opts       *ViewOptions
-		tty        bool
-		wantErr    bool
-		wantOut    string
-		browsedURL string
-		errMsg     string
+		name        string
+		httpStubs   func(*httpmock.Registry)
+		promptStubs func(*prompter.MockPrompter)
+		opts        *ViewOptions
+		tty         bool
+		wantErr     bool
+		wantOut     string
+		browsedURL  string
+		errMsg      string
 	}{
 		{
 			name: "associate with PR",
@@ -532,9 +532,12 @@ func TestViewRun(t *testing.T) {
 					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
 					httpmock.JSONResponse(shared.TestWorkflow))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
+					})
 			},
 			opts: &ViewOptions{
 				Prompt: true,
@@ -579,11 +582,17 @@ func TestViewRun(t *testing.T) {
 						},
 					}))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(1)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool job")
+					})
 			},
 			wantOut: coolJobRunLogOutput,
 		},
@@ -626,11 +635,17 @@ func TestViewRun(t *testing.T) {
 						},
 					}))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(1)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool job")
+					})
 			},
 			wantOut: coolJobRunLogOutput,
 		},
@@ -717,11 +732,17 @@ func TestViewRun(t *testing.T) {
 						},
 					}))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(0)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
 			},
 			wantOut: expectedRunLogOutput,
 		},
@@ -791,11 +812,17 @@ func TestViewRun(t *testing.T) {
 						},
 					}))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(4)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "X sad job")
+					})
 			},
 			wantOut: quuxTheBarfLogOutput,
 		},
@@ -838,11 +865,17 @@ func TestViewRun(t *testing.T) {
 						},
 					}))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(4)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "X sad job")
+					})
 			},
 			wantOut: quuxTheBarfLogOutput,
 		},
@@ -906,11 +939,17 @@ func TestViewRun(t *testing.T) {
 					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
 					httpmock.JSONResponse(shared.TestWorkflow))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(4)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(0)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
 			},
 			wantOut: quuxTheBarfLogOutput,
 		},
@@ -1054,11 +1093,17 @@ func TestViewRun(t *testing.T) {
 					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
 					httpmock.JSONResponse(shared.TestWorkflow))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(0)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
 			},
 			wantOut: "\n✓ trunk CI · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\nX sad job in 4m34s (ID 20)\n  ✓ barf the quux\n  X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n\nFor more information about a job, try: gh run view --job=<job-id>\nView this run on GitHub: https://github.com/runs/3\n",
 		},
@@ -1099,11 +1144,17 @@ func TestViewRun(t *testing.T) {
 					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
 					httpmock.JSONResponse(shared.TestWorkflow))
 			},
-			askStubs: func(as *prompt.AskStubber) {
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(2)
-				//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
-				as.StubOne(1)
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool job")
+					})
 			},
 			wantOut: "\n✓ trunk CI · 3\nTriggered via push about 59 minutes ago\n\n✓ cool job in 4m34s (ID 10)\n  ✓ fob the barz\n  ✓ barz the fob\n\nTo see the full job log, try: gh run view --log --job=10\nView this run on GitHub: https://github.com/runs/3\n",
 		},
@@ -1211,11 +1262,10 @@ func TestViewRun(t *testing.T) {
 			return ghrepo.FromFullName("OWNER/REPO")
 		}
 
-		//nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber
-		as, teardown := prompt.InitAskStubber()
-		defer teardown()
-		if tt.askStubs != nil {
-			tt.askStubs(as)
+		pm := prompter.NewMockPrompter(t)
+		tt.opts.Prompter = pm
+		if tt.promptStubs != nil {
+			tt.promptStubs(pm)
 		}
 
 		browser := &browser.Stub{}
diff --git a/pkg/cmd/run/watch/watch_test.go b/pkg/cmd/run/watch/watch_test.go
index 89c75e72c..8ff77516b 100644
--- a/pkg/cmd/run/watch/watch_test.go
+++ b/pkg/cmd/run/watch/watch_test.go
@@ -8,12 +8,12 @@ import (
 	"time"
 
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/run/shared"
 	workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 )
@@ -193,14 +193,14 @@ func TestWatchRun(t *testing.T) {
 	}
 
 	tests := []struct {
-		name      string
-		httpStubs func(*httpmock.Registry)
-		askStubs  func(*prompt.AskStubber)
-		opts      *WatchOptions
-		tty       bool
-		wantErr   bool
-		errMsg    string
-		wantOut   string
+		name        string
+		httpStubs   func(*httpmock.Registry)
+		promptStubs func(*prompter.MockPrompter)
+		opts        *WatchOptions
+		tty         bool
+		wantErr     bool
+		errMsg      string
+		wantOut     string
 	}{
 		{
 			name: "run ID provided run already completed",
@@ -269,10 +269,12 @@ func TestWatchRun(t *testing.T) {
 				Prompt:   true,
 			},
 			httpStubs: successfulRunStubs,
-			askStubs: func(as *prompt.AskStubber) {
-				as.StubPrompt("Select a workflow run").
-					AssertOptions([]string{"* commit1, CI (trunk) Feb 23, 2021", "* commit2, CI (trunk) Feb 23, 2021"}).
-					AnswerWith("* commit2, CI (trunk) Feb 23, 2021")
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"* commit1, CI (trunk) Feb 23, 2021", "* commit2, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "* commit2, CI (trunk) Feb 23, 2021")
+					})
 			},
 			wantOut: "\x1b[?1049h\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n* trunk CI · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n  ✓ fob the barz\n  ✓ barz the fob\n\x1b[?1049l✓ trunk CI · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n  ✓ fob the barz\n  ✓ barz the fob\n\n✓ Run CI (2) completed with 'success'\n",
 		},
@@ -285,10 +287,12 @@ func TestWatchRun(t *testing.T) {
 				ExitStatus: true,
 			},
 			httpStubs: failedRunStubs,
-			askStubs: func(as *prompt.AskStubber) {
-				as.StubPrompt("Select a workflow run").
-					AssertOptions([]string{"* commit1, CI (trunk) Feb 23, 2021", "* commit2, CI (trunk) Feb 23, 2021"}).
-					AnswerWith("* commit2, CI (trunk) Feb 23, 2021")
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"* commit1, CI (trunk) Feb 23, 2021", "* commit2, CI (trunk) Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "* commit2, CI (trunk) Feb 23, 2021")
+					})
 			},
 			wantOut: "\x1b[?1049h\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n* trunk CI · 2\nTriggered via push about 59 minutes ago\n\n\x1b[?1049lX trunk CI · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (ID 20)\n  ✓ barf the quux\n  X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n\nX Run CI (2) completed with 'failure'\n",
 			wantErr: true,
@@ -317,10 +321,10 @@ func TestWatchRun(t *testing.T) {
 		}
 
 		t.Run(tt.name, func(t *testing.T) {
-			//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
-			as := prompt.NewAskStubber(t)
-			if tt.askStubs != nil {
-				tt.askStubs(as)
+			pm := prompter.NewMockPrompter(t)
+			tt.opts.Prompter = pm
+			if tt.promptStubs != nil {
+				tt.promptStubs(pm)
 			}
 
 			err := watchRun(tt.opts)