Repository: cli/cli

Issue:
Support aliases deeper than top level, e.g. `gh pr co`
### Describe the feature or problem you’d like to solve

I type `gh pr checkout` _many_ times a day. Typing `checkout` is long. I have shell aliases for `git checkout`: `gco`. That muscle memory could carry over to `gh pr checkout` too, with the `gh pr co` command alias.

I'd like to think that this affects many people using `gh pr checkout`: not just me. But I've not done any research to verify that thought.

### Proposed solution

Add an [alias](https://godoc.org/github.com/spf13/cobra#Command) for `co` to the `pr_checkout` command.

I considered some alternatives, like the existing tab completions. While they do work as intended, `gh pr c<tab><tab>` and `gh pr ch<tab>` take three keypresses, and are different from the "usual" `co` abbreviation for `checkout`.

I've got the change ready to go locally - but as per the contributing guide, I'm submitting an issue first to see if there's an appetite for this!

### Additional context

Thanks for building the `gh` tool!


Discussion:
@mislav: Hi, thanks for explaining how you're using gh! 

Would it be an option for you to create a gh alias for `pr checkout`? E.g.
```
$ gh alias set co 'pr checkout'
$ gh co 123
```

In fact, I believe that we ship with this alias as default for new users who are installing GitHub CLI.@issyl0: `gh alias set co 'pr checkout'` works - thanks, @mislav. I didn't realise `alias set` was a thing. 😳

I did try out `gh alias set 'pr co' 'pr checkout'`, which doesn't seem to work. But I understand that keeping the `pr` namespacing is perhaps not the preferred way (even though it makes more sense to me as `co` operates on PRs in this case).@mislav: @issyl0 You're right, right now aliases are only supported at "top-level" and not nested under a command such as `pr`. We could potentially support nested ones too, but I don't see why someone would create a 2-word alias when they can create a 1-word one.

Do you feel that this can be closed or that we should keep it open as a feature request?@issyl0: Thanks for your responses. This is fine to close. If `gh co` still seems weird in a week or two, I can keep my local change to support `gh pr co`.@pataquets: Same here. In my usage case, I've tried to define `gh issue mine` as `issue list --author pataquets`﻿. Ended up implementing it as `issue.mine`, but supporting deeper aliases would open some meaningful shortcuts for common switches as described.
@mislav: I would like to ask this issue to be left open to keep track of it, in case sometime in the future it could be implemented, although I understand it might not be a priority now.
@mislav: That's a good point! Reopening@mathieutu: The same here.
I was trying to add `gh pr browse` for ` pr view -w` like we have `gh browse`.@cravend: Just wanted to drop my use-case in this issue (I didn't see it when I opened my own!).

I have `gh go` aliased to `gh repo view --web`, but I'd like to extend that to other commands (e.g. `gh pr go` for `gh pr view --web`). I could do something like `gh prgo` (one word) but it's more natural to me to think of `go` = `view --web`.

@koppor: Is this the place to ask for `gh pr co {num}` out of the box.

In the default setting one needs to omit `pr`, which is confusing (because it is not consistent why one needs to write `checkout` in one place and `co` in another place although both terms have the same effect)

```bash
$ gh pr co 13280
unknown command "co" for "gh pr"
$ gh co 13280
remote: Enumerating objects: 178, done.
```@BagToad: @koppor I'd say you should make a new issue for that. We could always add new default aliases, but need to discuss it. 

Solution 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)