package copilot import ( "archive/tar" "archive/zip" "bufio" "compress/gzip" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "slices" "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" ghzip "github.com/cli/cli/v2/internal/zip" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" ) type CopilotOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Prompter prompter.Prompter CopilotArgs []string Remove bool } func NewCmdCopilot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*CopilotOptions) error) *cobra.Command { opts := &CopilotOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Prompter: f.Prompter, } cmd := &cobra.Command{ Use: "copilot [flags] [args]", Short: "Run the GitHub Copilot CLI (preview)", Long: heredoc.Docf(` Runs the GitHub Copilot CLI. Executing the Copilot CLI through %[1]sgh%[1]s is currently in preview and subject to change. If already installed, %[1]sgh%[1]s will execute the Copilot CLI found in your %[1]sPATH%[1]s. If the Copilot CLI is not installed, it will be downloaded to %[2]s. Use %[1]s--remove%[1]s to remove the downloaded Copilot CLI. This command is only supported on Windows, Linux, and Darwin, on amd64/x64 or arm64 architectures. To prevent %[1]sgh%[1]s from interpreting flags intended for Copilot, use %[1]s--%[1]s before Copilot flags and args. Learn more at https://gh.io/copilot-cli `, "`", copilotInstallDir()), Example: heredoc.Doc(` # Download and run the Copilot CLI $ gh copilot # Run the Copilot CLI $ gh copilot -p "Summarize this week's commits" --allow-tool 'shell(git)' # Remove the Copilot CLI (if installed through gh) $ gh copilot --remove # Run the Copilot CLI help command $ gh copilot -- --help `), DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { telemetry.SetSampleRate(ghtelemetry.SAMPLE_ALL) stopParsePos := -1 for i, arg := range args { if arg == "--" { stopParsePos = i break } } ghArgs := args opts.CopilotArgs = args if stopParsePos >= 0 { ghArgs = args[:stopParsePos] opts.CopilotArgs = args[stopParsePos+1:] // +1 to skip the "--" itself } if slices.Contains(ghArgs, "--help") || slices.Contains(ghArgs, "-h") { return cmd.Help() } if slices.Contains(ghArgs, "--remove") { hasOtherArgs := len(ghArgs) > 1 if stopParsePos >= 0 { hasOtherArgs = hasOtherArgs || len(opts.CopilotArgs) > 0 } if hasOtherArgs { return cmdutil.FlagErrorf("cannot use --remove with args") } opts.Remove = true opts.CopilotArgs = nil } if runF != nil { return runF(opts) } return runCopilot(opts) }, } cmdutil.DisableAuthCheck(cmd) // We add this flag, even though flag parsing is disabled for this command // so the flag still appears in the help text. cmd.Flags().Bool("remove", false, "Remove the downloaded Copilot CLI") return cmd } func runCopilot(opts *CopilotOptions) error { if opts.Remove { if err := removeCopilot(copilotInstallDir()); err != nil { return err } if opts.IO.IsStdoutTTY() { fmt.Fprintln(opts.IO.ErrOut, "Copilot CLI removed successfully") } return nil } copilotPath := findCopilotBinaryFunc() foundInPath := copilotPath != "" if !foundInPath { if opts.IO.CanPrompt() { confirmed, err := opts.Prompter.Confirm("GitHub Copilot CLI is not installed. Would you like to install it?", true) if err != nil { return err } if !confirmed { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI was not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } } else if !ci.IsCI() { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } httpClient, err := opts.HttpClient() if err != nil { return err } copilotPath, err = downloadCopilot(httpClient, opts.IO, copilotInstallDir(), copilotBinaryPath()) if err != nil { return err } } externalCmd := exec.Command(copilotPath, opts.CopilotArgs...) externalCmd.Stdin = opts.IO.In externalCmd.Stdout = opts.IO.Out externalCmd.Stderr = opts.IO.ErrOut externalCmd.Env = append(os.Environ(), "COPILOT_GH=true") if err := runExternalCmdFunc(externalCmd); err != nil { if exitErr, ok := err.(*exec.ExitError); ok { // We terminate with os.Exit here, preserving the exit code from Copilot CLI, // and also preventing stdio writes by callers up the stack. os.Exit(exitErr.ExitCode()) } if foundInPath { // We found a `copilot` binary but exec failed, possibly due to // unusual characters in the path (see https://github.com/cli/cli/issues/13106). // Suggest running copilot directly as a workaround. return fmt.Errorf("%w\nFailed to run '%s', try running `copilot` directly without `gh`.", err, copilotPath) } return err } return nil } const copilotBinaryName = "copilot" func copilotInstallDir() string { return filepath.Join(config.DataDir(), "copilot") } func copilotBinaryPath() string { binaryName := copilotBinaryName if runtime.GOOS == "windows" { binaryName += ".exe" } return filepath.Join(copilotInstallDir(), binaryName) } var runExternalCmdFunc = runExternalCmd func runExternalCmd(cmd *exec.Cmd) error { return cmd.Run() } var findCopilotBinaryFunc = findCopilotBinary // findCopilotBinary returns the path to the Copilot CLI binary, if installed, // with the following order of precedence: // 1. `copilot` in the PATH // 2. `copilot` in gh's data directory // // If not installed, it returns an empty string. func findCopilotBinary() string { if path, err := exec.LookPath(copilotBinaryName); err == nil { return path } localPath := copilotBinaryPath() if _, err := os.Stat(localPath); err != nil { return "" } return localPath } // downloadCopilot downloads and installs the Copilot CLI to installDir. // It returns the path to the installed Copilot binary. func downloadCopilot(httpClient *http.Client, ios *iostreams.IOStreams, installDir, localPath string) (string, error) { platform := runtime.GOOS if platform == "windows" { platform = "win32" } arch := runtime.GOARCH if arch == "amd64" { arch = "x64" } if arch != "x64" && arch != "arm64" { return "", fmt.Errorf("unsupported architecture: %s (supported: x64, arm64)", arch) } var archiveURL string var archiveName string var isZip bool switch platform { case "win32": archiveName = fmt.Sprintf("copilot-%s-%s.zip", platform, arch) archiveURL = fmt.Sprintf("https://github.com/github/copilot-cli/releases/latest/download/%s", archiveName) isZip = true case "linux", "darwin": archiveName = fmt.Sprintf("copilot-%s-%s.tar.gz", platform, arch) archiveURL = fmt.Sprintf("https://github.com/github/copilot-cli/releases/latest/download/%s", archiveName) default: return "", fmt.Errorf("unsupported platform: %s (supported: linux, darwin, windows)", platform) } checksumsURL := "https://github.com/github/copilot-cli/releases/latest/download/SHA256SUMS.txt" expectedChecksum, err := fetchExpectedChecksum(httpClient, checksumsURL, archiveName) if err != nil { return "", fmt.Errorf("failed to fetch checksums: %w", err) } ios.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading Copilot CLI from %s", archiveURL)) defer ios.StopProgressIndicator() resp, err := httpClient.Get(archiveURL) if err != nil { return "", fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download failed with status: %s", resp.Status) } // Download to temp file while calculating checksum tmpFile, err := os.CreateTemp("", "copilot-download-*") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } defer os.Remove(tmpFile.Name()) defer tmpFile.Close() hasher := sha256.New() if _, err := io.Copy(tmpFile, io.TeeReader(resp.Body, hasher)); err != nil { return "", fmt.Errorf("failed to download: %w", err) } ios.StopProgressIndicator() // Validate checksum actualChecksumHex := hex.EncodeToString(hasher.Sum(nil)) if actualChecksumHex != expectedChecksum { return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksumHex) } if _, err := tmpFile.Seek(0, io.SeekStart); err != nil { return "", fmt.Errorf("failed to seek temp file: %w", err) } if err := os.MkdirAll(installDir, 0755); err != nil { return "", fmt.Errorf("failed to create install directory: %w", err) } // Extract from the downloaded data if isZip { err = extractZip(tmpFile.Name(), installDir) } else { err = extractTarGz(tmpFile, installDir) } if err != nil { return "", err } if _, err := os.Stat(localPath); err != nil { return "", fmt.Errorf("copilot binary unavailable: %w", err) } fmt.Fprintf(ios.ErrOut, "%s Copilot CLI installed successfully\n", ios.ColorScheme().SuccessIcon()) return localPath, nil } // fetchExpectedChecksum downloads the SHA256SUMS.txt file and returns the expected checksum for the given archive name. func fetchExpectedChecksum(httpClient *http.Client, checksumsURL, archiveName string) (string, error) { resp, err := httpClient.Get(checksumsURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to download checksums: %s", resp.Status) } // Parse the checksums file. Possible formats are: // - " " (two whitespaces) // - " " scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() fields := strings.Fields(line) if len(fields) >= 2 { checksum := fields[0] filename := fields[1] if filename == archiveName { return checksum, nil } } } if err := scanner.Err(); err != nil { return "", fmt.Errorf("failed to read checksums: %w", err) } return "", fmt.Errorf("checksum not found for %s", archiveName) } // extractZip reads a ZIP archive at path and extracts its contents into destDir. // It returns an error if the archive cannot be read, // or if any file or directory within the archive cannot be created or written. func extractZip(path, destDir string) error { zipReader, err := zip.OpenReader(path) if err != nil { return fmt.Errorf("failed to open zip: %w", err) } defer zipReader.Close() absPath, err := safepaths.ParseAbsolute(destDir) if err != nil { return err } // As of the time of writing, ghzip.ExtractZip will safely skip files that // would result in path traversal. This is an issue for our use-case because // we want to error out before extracting if there's any such file. // To avoid breaking the shared ghzip.ExtractZip code that expects unsafe // paths to be ignored and no error produced, we pre-validate here, // producing an error if any such file is found. for _, f := range zipReader.File { _, err := absPath.Join(f.Name) if err != nil { return err } } if err := ghzip.ExtractZip(&zipReader.Reader, absPath); err != nil { return err } return nil } // extractTarGz reads a TAR.GZ archive from r and extracts its contents into destDir. // It returns an error if the archive cannot be read, // or if any file or directory within the archive cannot be created or written. func extractTarGz(r io.Reader, destDir string) error { gzr, err := gzip.NewReader(r) if err != nil { return fmt.Errorf("failed to create gzip reader: %w", err) } defer gzr.Close() absDestDirPath, err := safepaths.ParseAbsolute(destDir) if err != nil { return err } tr := tar.NewReader(gzr) for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return fmt.Errorf("failed to read tar: %w", err) } absFilePath, err := absDestDirPath.Join(header.Name) if err != nil { return err } target := absFilePath.String() if header.Typeflag == tar.TypeReg { if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return fmt.Errorf("failed to create parent directory: %w", err) } if err := extractFile(target, os.FileMode(header.Mode)&0777, tr); err != nil { return err } } } return nil } // extractFile creates a file at target with the given mode and copies content from r. func extractFile(target string, mode os.FileMode, r io.Reader) (err error) { out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer func() { if cerr := out.Close(); err == nil && cerr != nil { err = fmt.Errorf("failed to close file: %w", cerr) } }() if _, err := io.Copy(out, r); err != nil { return fmt.Errorf("failed to write file: %w", err) } return nil } func removeCopilot(installDir string) error { if _, err := os.Stat(installDir); os.IsNotExist(err) { return fmt.Errorf("failed to remove Copilot CLI: Copilot CLI not installed through `gh`") } if err := os.RemoveAll(installDir); err != nil { return fmt.Errorf("failed to remove Copilot CLI: %w", err) } return nil }