github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/extension/command.go (about)

     1  package extension
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"os"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/MakeNowJust/heredoc"
    11  	"github.com/ungtb10d/cli/v2/api"
    12  	"github.com/ungtb10d/cli/v2/git"
    13  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    14  	"github.com/ungtb10d/cli/v2/internal/tableprinter"
    15  	"github.com/ungtb10d/cli/v2/internal/text"
    16  	"github.com/ungtb10d/cli/v2/pkg/cmd/extension/browse"
    17  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    18  	"github.com/ungtb10d/cli/v2/pkg/extensions"
    19  	"github.com/ungtb10d/cli/v2/pkg/search"
    20  	"github.com/ungtb10d/cli/v2/utils"
    21  	"github.com/spf13/cobra"
    22  )
    23  
    24  func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
    25  	m := f.ExtensionManager
    26  	io := f.IOStreams
    27  	prompter := f.Prompter
    28  	config := f.Config
    29  	browser := f.Browser
    30  	httpClient := f.HttpClient
    31  
    32  	extCmd := cobra.Command{
    33  		Use:   "extension",
    34  		Short: "Manage gh extensions",
    35  		Long: heredoc.Docf(`
    36  			GitHub CLI extensions are repositories that provide additional gh commands.
    37  
    38  			The name of the extension repository must start with "gh-" and it must contain an
    39  			executable of the same name. All arguments passed to the %[1]sgh <extname>%[1]s invocation
    40  			will be forwarded to the %[1]sgh-<extname>%[1]s executable of the extension.
    41  
    42  			An extension cannot override any of the core gh commands. If an extension name conflicts
    43  			with a core gh command you can use %[1]sgh extension exec <extname>%[1]s.
    44  
    45  			See the list of available extensions at <https://github.com/topics/gh-extension>.
    46  		`, "`"),
    47  		Aliases: []string{"extensions", "ext"},
    48  	}
    49  
    50  	extCmd.AddCommand(
    51  		func() *cobra.Command {
    52  			query := search.Query{
    53  				Kind: search.KindRepositories,
    54  			}
    55  			qualifiers := search.Qualifiers{
    56  				Topic: []string{"gh-extension"},
    57  			}
    58  			var order string
    59  			var sort string
    60  			var webMode bool
    61  			var exporter cmdutil.Exporter
    62  
    63  			cmd := &cobra.Command{
    64  				Use:   "search [<query>]",
    65  				Short: "Search extensions to the GitHub CLI",
    66  				Long: heredoc.Doc(`
    67  					Search for gh extensions.
    68  
    69  					With no arguments, this command prints out the first 30 extensions
    70  					available to install sorted by number of stars. More extensions can
    71  					be fetched by specifying a higher limit with the --limit flag.
    72  
    73  					When connected to a terminal, this command prints out three columns.
    74  					The first has a ✓ if the extension is already installed locally. The
    75  					second is the full name of the extension repository in NAME/OWNER
    76  					format. The third is the extension's description.
    77  
    78  					When not connected to a terminal, the ✓ character is rendered as the
    79  					word "installed" but otherwise the order and content of the columns
    80  					is the same.
    81  
    82  					This command behaves similarly to 'gh search repos' but does not
    83  					support as many search qualifiers. For a finer grained search of
    84  					extensions, try using:
    85  
    86  						gh search repos --topic "gh-extension"
    87  
    88  					and adding qualifiers as needed. See 'gh help search repos' to learn
    89  					more about repository search.
    90  
    91  					For listing just the extensions that are already installed locally,
    92  					see:
    93  
    94  						gh ext list
    95  				`),
    96  				Example: heredoc.Doc(`
    97  					# List the first 30 extensions sorted by star count, descending
    98  					$ gh ext search
    99  
   100  					# List more extensions
   101  					$ gh ext search --limit 300
   102  
   103  					# List extensions matching the term "branch"
   104  					$ gh ext search branch
   105  
   106  					# List extensions owned by organization "github"
   107  					$ gh ext search --owner github
   108  
   109  					# List extensions, sorting by recently updated, ascending
   110  					$ gh ext search --sort updated --order asc
   111  
   112  					# List extensions, filtering by license
   113  					$ gh ext search --license MIT
   114  
   115  					# Open search results in the browser
   116  					$ gh ext search -w
   117  				`),
   118  				RunE: func(cmd *cobra.Command, args []string) error {
   119  					cfg, err := config()
   120  					if err != nil {
   121  						return err
   122  					}
   123  					client, err := httpClient()
   124  					if err != nil {
   125  						return err
   126  					}
   127  
   128  					if cmd.Flags().Changed("order") {
   129  						query.Order = order
   130  					}
   131  					if cmd.Flags().Changed("sort") {
   132  						query.Sort = sort
   133  					}
   134  
   135  					query.Keywords = args
   136  					query.Qualifiers = qualifiers
   137  
   138  					host, _ := cfg.DefaultHost()
   139  					searcher := search.NewSearcher(client, host)
   140  
   141  					if webMode {
   142  						url := searcher.URL(query)
   143  						if io.IsStdoutTTY() {
   144  							fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(url))
   145  						}
   146  						return browser.Browse(url)
   147  					}
   148  
   149  					io.StartProgressIndicator()
   150  					result, err := searcher.Repositories(query)
   151  					io.StopProgressIndicator()
   152  					if err != nil {
   153  						return err
   154  					}
   155  
   156  					if exporter != nil {
   157  						return exporter.Write(io, result.Items)
   158  					}
   159  
   160  					if io.IsStdoutTTY() {
   161  						if len(result.Items) == 0 {
   162  							return errors.New("no extensions found")
   163  						}
   164  						fmt.Fprintf(io.Out, "Showing %d of %d extensions\n", len(result.Items), result.Total)
   165  						fmt.Fprintln(io.Out)
   166  					}
   167  
   168  					cs := io.ColorScheme()
   169  					installedExts := m.List()
   170  
   171  					isInstalled := func(repo search.Repository) bool {
   172  						searchRepo, err := ghrepo.FromFullName(repo.FullName)
   173  						if err != nil {
   174  							return false
   175  						}
   176  						for _, e := range installedExts {
   177  							// TODO consider a Repo() on Extension interface
   178  							if u, err := git.ParseURL(e.URL()); err == nil {
   179  								if r, err := ghrepo.FromURL(u); err == nil {
   180  									if ghrepo.IsSame(searchRepo, r) {
   181  										return true
   182  									}
   183  								}
   184  							}
   185  						}
   186  						return false
   187  					}
   188  
   189  					tp := tableprinter.New(io)
   190  					tp.HeaderRow("", "REPO", "DESCRIPTION")
   191  
   192  					for _, repo := range result.Items {
   193  						if !strings.HasPrefix(repo.Name, "gh-") {
   194  							continue
   195  						}
   196  
   197  						installed := ""
   198  						if isInstalled(repo) {
   199  							if io.IsStdoutTTY() {
   200  								installed = "✓"
   201  							} else {
   202  								installed = "installed"
   203  							}
   204  						}
   205  
   206  						tp.AddField(installed, tableprinter.WithColor(cs.Green))
   207  						tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold))
   208  						tp.AddField(repo.Description)
   209  						tp.EndRow()
   210  					}
   211  
   212  					return tp.Render()
   213  				},
   214  			}
   215  
   216  			// Output flags
   217  			cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Open the search query in the web browser")
   218  			cmdutil.AddJSONFlags(cmd, &exporter, search.RepositoryFields)
   219  
   220  			// Query parameter flags
   221  			cmd.Flags().IntVarP(&query.Limit, "limit", "L", 30, "Maximum number of extensions to fetch")
   222  			cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of repositories returned, ignored unless '--sort' flag is specified")
   223  			cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"forks", "help-wanted-issues", "stars", "updated"}, "Sort fetched repositories")
   224  
   225  			// Qualifier flags
   226  			cmd.Flags().StringSliceVar(&qualifiers.License, "license", nil, "Filter based on license type")
   227  			cmd.Flags().StringVar(&qualifiers.User, "owner", "", "Filter on owner")
   228  
   229  			return cmd
   230  		}(),
   231  		&cobra.Command{
   232  			Use:     "list",
   233  			Short:   "List installed extension commands",
   234  			Aliases: []string{"ls"},
   235  			Args:    cobra.NoArgs,
   236  			RunE: func(cmd *cobra.Command, args []string) error {
   237  				cmds := m.List()
   238  				if len(cmds) == 0 {
   239  					return cmdutil.NewNoResultsError("no installed extensions found")
   240  				}
   241  				cs := io.ColorScheme()
   242  				//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
   243  				t := utils.NewTablePrinter(io)
   244  				for _, c := range cmds {
   245  					// TODO consider a Repo() on Extension interface
   246  					var repo string
   247  					if u, err := git.ParseURL(c.URL()); err == nil {
   248  						if r, err := ghrepo.FromURL(u); err == nil {
   249  							repo = ghrepo.FullName(r)
   250  						}
   251  					}
   252  
   253  					t.AddField(fmt.Sprintf("gh %s", c.Name()), nil, nil)
   254  					t.AddField(repo, nil, nil)
   255  					version := displayExtensionVersion(c, c.CurrentVersion())
   256  					if c.IsPinned() {
   257  						t.AddField(version, nil, cs.Cyan)
   258  					} else {
   259  						t.AddField(version, nil, nil)
   260  					}
   261  
   262  					t.EndRow()
   263  				}
   264  				return t.Render()
   265  			},
   266  		},
   267  		func() *cobra.Command {
   268  			var pinFlag string
   269  			cmd := &cobra.Command{
   270  				Use:   "install <repository>",
   271  				Short: "Install a gh extension from a repository",
   272  				Long: heredoc.Doc(`
   273  					Install a GitHub repository locally as a GitHub CLI extension.
   274  
   275  					The repository argument can be specified in "owner/repo" format as well as a full URL.
   276  					The URL format is useful when the repository is not hosted on github.com.
   277  
   278  					To install an extension in development from the current directory, use "." as the
   279  					value of the repository argument.
   280  
   281  					See the list of available extensions at <https://github.com/topics/gh-extension>.
   282  				`),
   283  				Example: heredoc.Doc(`
   284  					$ gh extension install owner/gh-extension
   285  					$ gh extension install https://git.example.com/owner/gh-extension
   286  					$ gh extension install .
   287  				`),
   288  				Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"),
   289  				RunE: func(cmd *cobra.Command, args []string) error {
   290  					if args[0] == "." {
   291  						if pinFlag != "" {
   292  							return fmt.Errorf("local extensions cannot be pinned")
   293  						}
   294  						wd, err := os.Getwd()
   295  						if err != nil {
   296  							return err
   297  						}
   298  						return m.InstallLocal(wd)
   299  					}
   300  
   301  					repo, err := ghrepo.FromFullName(args[0])
   302  					if err != nil {
   303  						return err
   304  					}
   305  
   306  					if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil {
   307  						return err
   308  					}
   309  
   310  					cs := io.ColorScheme()
   311  					if err := m.Install(repo, pinFlag); err != nil {
   312  						if errors.Is(err, releaseNotFoundErr) {
   313  							return fmt.Errorf("%s Could not find a release of %s for %s",
   314  								cs.FailureIcon(), args[0], cs.Cyan(pinFlag))
   315  						} else if errors.Is(err, commitNotFoundErr) {
   316  							return fmt.Errorf("%s %s does not exist in %s",
   317  								cs.FailureIcon(), cs.Cyan(pinFlag), args[0])
   318  						} else if errors.Is(err, repositoryNotFoundErr) {
   319  							return fmt.Errorf("%s Could not find extension '%s' on host %s",
   320  								cs.FailureIcon(), args[0], repo.RepoHost())
   321  						}
   322  						return err
   323  					}
   324  
   325  					if io.IsStdoutTTY() {
   326  						fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0])
   327  						if pinFlag != "" {
   328  							fmt.Fprintf(io.Out, "%s Pinned extension at %s\n", cs.SuccessIcon(), cs.Cyan(pinFlag))
   329  						}
   330  					}
   331  					return nil
   332  				},
   333  			}
   334  			cmd.Flags().StringVar(&pinFlag, "pin", "", "pin extension to a release tag or commit ref")
   335  			return cmd
   336  		}(),
   337  		func() *cobra.Command {
   338  			var flagAll bool
   339  			var flagForce bool
   340  			var flagDryRun bool
   341  			cmd := &cobra.Command{
   342  				Use:   "upgrade {<name> | --all}",
   343  				Short: "Upgrade installed extensions",
   344  				Args: func(cmd *cobra.Command, args []string) error {
   345  					if len(args) == 0 && !flagAll {
   346  						return cmdutil.FlagErrorf("specify an extension to upgrade or `--all`")
   347  					}
   348  					if len(args) > 0 && flagAll {
   349  						return cmdutil.FlagErrorf("cannot use `--all` with extension name")
   350  					}
   351  					if len(args) > 1 {
   352  						return cmdutil.FlagErrorf("too many arguments")
   353  					}
   354  					return nil
   355  				},
   356  				RunE: func(cmd *cobra.Command, args []string) error {
   357  					var name string
   358  					if len(args) > 0 {
   359  						name = normalizeExtensionSelector(args[0])
   360  					}
   361  					if flagDryRun {
   362  						m.EnableDryRunMode()
   363  					}
   364  					cs := io.ColorScheme()
   365  					err := m.Upgrade(name, flagForce)
   366  					if err != nil && !errors.Is(err, upToDateError) {
   367  						if name != "" {
   368  							fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err)
   369  						} else if errors.Is(err, noExtensionsInstalledError) {
   370  							return cmdutil.NewNoResultsError("no installed extensions found")
   371  						} else {
   372  							fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions\n", cs.FailureIcon())
   373  						}
   374  						return cmdutil.SilentError
   375  					}
   376  					if io.IsStdoutTTY() {
   377  						successStr := "Successfully"
   378  						if flagDryRun {
   379  							successStr = "Would have"
   380  						}
   381  						if errors.Is(err, upToDateError) {
   382  							fmt.Fprintf(io.Out, "%s Extension already up to date\n", cs.SuccessIcon())
   383  						} else if name != "" {
   384  							fmt.Fprintf(io.Out, "%s %s upgraded extension %s\n", cs.SuccessIcon(), successStr, name)
   385  						} else {
   386  							fmt.Fprintf(io.Out, "%s %s upgraded extensions\n", cs.SuccessIcon(), successStr)
   387  						}
   388  					}
   389  					return nil
   390  				},
   391  			}
   392  			cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions")
   393  			cmd.Flags().BoolVar(&flagForce, "force", false, "Force upgrade extension")
   394  			cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Only display upgrades")
   395  			return cmd
   396  		}(),
   397  		&cobra.Command{
   398  			Use:   "remove <name>",
   399  			Short: "Remove an installed extension",
   400  			Args:  cobra.ExactArgs(1),
   401  			RunE: func(cmd *cobra.Command, args []string) error {
   402  				extName := normalizeExtensionSelector(args[0])
   403  				if err := m.Remove(extName); err != nil {
   404  					return err
   405  				}
   406  				if io.IsStdoutTTY() {
   407  					cs := io.ColorScheme()
   408  					fmt.Fprintf(io.Out, "%s Removed extension %s\n", cs.SuccessIcon(), extName)
   409  				}
   410  				return nil
   411  			},
   412  		},
   413  		func() *cobra.Command {
   414  			var debug bool
   415  			cmd := &cobra.Command{
   416  				Use:   "browse",
   417  				Short: "Enter a UI for browsing, adding, and removing extensions",
   418  				Long: heredoc.Doc(`
   419  					This command will take over your terminal and run a fully interactive
   420  					interface for browsing, adding, and removing gh extensions.
   421  
   422  					The extension list is navigated with the arrow keys or with j/k.
   423  					Space and control+space (or control + j/k) page the list up and down.
   424  					Extension readmes can be scrolled with page up/page down keys
   425  					(fn + arrow up/down on a mac keyboard).
   426  
   427  					For highlighted extensions, you can press:
   428  
   429  					- w to open the extension in your web browser
   430  					- i to install the extension
   431  					- r to remove the extension
   432  
   433  					Press / to focus the filter input. Press enter to scroll the results.
   434  					Press Escape to clear the filter and return to the full list.
   435  
   436  					Press q to quit.
   437  
   438  					The output of this command may be difficult to navigate for screen reader
   439  					users, users operating at high zoom and other users of assistive technology. It
   440  					is also not advised for automation scripts. We advise those users to use the
   441  					alternative command:
   442  
   443  						gh ext search
   444  
   445  					along with gh ext install, gh ext remove, and gh repo view.
   446  				`),
   447  				Args: cobra.NoArgs,
   448  				RunE: func(cmd *cobra.Command, args []string) error {
   449  					if !io.CanPrompt() {
   450  						return errors.New("this command runs an interactive UI and needs to be run in a terminal")
   451  					}
   452  					cfg, err := config()
   453  					if err != nil {
   454  						return err
   455  					}
   456  					host, _ := cfg.DefaultHost()
   457  					client, err := f.HttpClient()
   458  					if err != nil {
   459  						return err
   460  					}
   461  
   462  					searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host)
   463  
   464  					opts := browse.ExtBrowseOpts{
   465  						Cmd:      cmd,
   466  						IO:       io,
   467  						Browser:  browser,
   468  						Searcher: searcher,
   469  						Em:       m,
   470  						Client:   client,
   471  						Cfg:      cfg,
   472  						Debug:    debug,
   473  					}
   474  
   475  					return browse.ExtBrowse(opts)
   476  				},
   477  			}
   478  			cmd.Flags().BoolVar(&debug, "debug", false, "log to /tmp/extBrowse-*")
   479  			return cmd
   480  		}(),
   481  		&cobra.Command{
   482  			Use:   "exec <name> [args]",
   483  			Short: "Execute an installed extension",
   484  			Long: heredoc.Doc(`
   485  				Execute an extension using the short name. For example, if the extension repository is
   486  				"owner/gh-extension", you should pass "extension". You can use this command when
   487  				the short name conflicts with a core gh command.
   488  
   489  				All arguments after the extension name will be forwarded to the executable
   490  				of the extension.
   491  			`),
   492  			Example: heredoc.Doc(`
   493  				# execute a label extension instead of the core gh label command
   494  				$ gh extension exec label
   495  			`),
   496  			Args:               cobra.MinimumNArgs(1),
   497  			DisableFlagParsing: true,
   498  			RunE: func(cmd *cobra.Command, args []string) error {
   499  				if found, err := m.Dispatch(args, io.In, io.Out, io.ErrOut); !found {
   500  					return fmt.Errorf("extension %q not found", args[0])
   501  				} else {
   502  					return err
   503  				}
   504  			},
   505  		},
   506  		func() *cobra.Command {
   507  			promptCreate := func() (string, extensions.ExtTemplateType, error) {
   508  				extName, err := prompter.Input("Extension name:", "")
   509  				if err != nil {
   510  					return extName, -1, err
   511  				}
   512  				options := []string{"Script (Bash, Ruby, Python, etc)", "Go", "Other Precompiled (C++, Rust, etc)"}
   513  				extTmplType, err := prompter.Select("What kind of extension?",
   514  					options[0],
   515  					options)
   516  				return extName, extensions.ExtTemplateType(extTmplType), err
   517  			}
   518  			var flagType string
   519  			cmd := &cobra.Command{
   520  				Use:   "create [<name>]",
   521  				Short: "Create a new extension",
   522  				Example: heredoc.Doc(`
   523  					# Use interactively
   524  					gh extension create
   525  
   526  					# Create a script-based extension
   527  					gh extension create foobar
   528  
   529  					# Create a Go extension
   530  					gh extension create --precompiled=go foobar
   531  
   532  					# Create a non-Go precompiled extension
   533  					gh extension create --precompiled=other foobar
   534  				`),
   535  				Args: cobra.MaximumNArgs(1),
   536  				RunE: func(cmd *cobra.Command, args []string) error {
   537  					if cmd.Flags().Changed("precompiled") {
   538  						if flagType != "go" && flagType != "other" {
   539  							return cmdutil.FlagErrorf("value for --precompiled must be 'go' or 'other'. Got '%s'", flagType)
   540  						}
   541  					}
   542  					var extName string
   543  					var err error
   544  					tmplType := extensions.GitTemplateType
   545  					if len(args) == 0 {
   546  						if io.IsStdoutTTY() {
   547  							extName, tmplType, err = promptCreate()
   548  							if err != nil {
   549  								return fmt.Errorf("could not prompt: %w", err)
   550  							}
   551  						}
   552  					} else {
   553  						extName = args[0]
   554  						if flagType == "go" {
   555  							tmplType = extensions.GoBinTemplateType
   556  						} else if flagType == "other" {
   557  							tmplType = extensions.OtherBinTemplateType
   558  						}
   559  					}
   560  
   561  					var fullName string
   562  
   563  					if strings.HasPrefix(extName, "gh-") {
   564  						fullName = extName
   565  						extName = extName[3:]
   566  					} else {
   567  						fullName = "gh-" + extName
   568  					}
   569  					if err := m.Create(fullName, tmplType); err != nil {
   570  						return err
   571  					}
   572  					if !io.IsStdoutTTY() {
   573  						return nil
   574  					}
   575  
   576  					var goBinChecks string
   577  
   578  					steps := fmt.Sprintf(
   579  						"- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action",
   580  						fullName, extName)
   581  
   582  					cs := io.ColorScheme()
   583  					if tmplType == extensions.GoBinTemplateType {
   584  						goBinChecks = heredoc.Docf(`
   585  						%[1]s Downloaded Go dependencies
   586  						%[1]s Built %[2]s binary
   587  						`, cs.SuccessIcon(), fullName)
   588  						steps = heredoc.Docf(`
   589  						- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action
   590  						- use 'go build && gh %[2]s' to see changes in your code as you develop`, fullName, extName)
   591  					} else if tmplType == extensions.OtherBinTemplateType {
   592  						steps = heredoc.Docf(`
   593  						- run 'cd %[1]s; gh extension install .' to install your extension locally
   594  						- fill in script/build.sh with your compilation script for automated builds
   595  						- compile a %[1]s binary locally and run 'gh %[2]s' to see changes`, fullName, extName)
   596  					}
   597  					link := "https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions"
   598  					out := heredoc.Docf(`
   599  						%[1]s Created directory %[2]s
   600  						%[1]s Initialized git repository
   601  						%[1]s Set up extension scaffolding
   602  						%[6]s
   603  						%[2]s is ready for development!
   604  
   605  						%[4]s
   606  						%[5]s
   607  						- commit and use 'gh repo create' to share your extension with others
   608  
   609  						For more information on writing extensions:
   610  						%[3]s
   611  					`, cs.SuccessIcon(), fullName, link, cs.Bold("Next Steps"), steps, goBinChecks)
   612  					fmt.Fprint(io.Out, out)
   613  					return nil
   614  				},
   615  			}
   616  			cmd.Flags().StringVar(&flagType, "precompiled", "", "Create a precompiled extension. Possible values: go, other")
   617  			return cmd
   618  		}(),
   619  	)
   620  
   621  	return &extCmd
   622  }
   623  
   624  func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager, extName string) error {
   625  	if !strings.HasPrefix(extName, "gh-") {
   626  		return errors.New("extension repository name must start with `gh-`")
   627  	}
   628  
   629  	commandName := strings.TrimPrefix(extName, "gh-")
   630  	if c, _, err := rootCmd.Traverse([]string{commandName}); err != nil {
   631  		return err
   632  	} else if c != rootCmd {
   633  		return fmt.Errorf("%q matches the name of a built-in command", commandName)
   634  	}
   635  
   636  	for _, ext := range m.List() {
   637  		if ext.Name() == commandName {
   638  			return fmt.Errorf("there is already an installed extension that provides the %q command", commandName)
   639  		}
   640  	}
   641  
   642  	return nil
   643  }
   644  
   645  func normalizeExtensionSelector(n string) string {
   646  	if idx := strings.IndexRune(n, '/'); idx >= 0 {
   647  		n = n[idx+1:]
   648  	}
   649  	return strings.TrimPrefix(n, "gh-")
   650  }
   651  
   652  func displayExtensionVersion(ext extensions.Extension, version string) string {
   653  	if !ext.IsBinary() && len(version) > 8 {
   654  		return version[:8]
   655  	}
   656  	return version
   657  }