feat: add github copilot support

Andrey Nering , Mehmet Arif Γ‡elik , and Mikhail Lukianchenko created

Co-authored-by: Mehmet Arif Γ‡elik <arif-celik56@hotmail.com>
Co-authored-by: Mikhail Lukianchenko <42915+mikluko@users.noreply.github.com>

Change summary

go.mod                          |   4 
go.sum                          |   4 
internal/agent/agent.go         |  12 +
internal/cmd/login.go           |  85 ++++++++++++++
internal/config/config.go       |  38 +++++-
internal/config/copilot.go      |  43 +++++++
internal/config/load.go         |  14 ++
internal/oauth/copilot/disk.go  |  36 ++++++
internal/oauth/copilot/http.go  |  17 ++
internal/oauth/copilot/oauth.go | 199 +++++++++++++++++++++++++++++++++++
internal/oauth/token.go         |   5 
11 files changed, 440 insertions(+), 17 deletions(-)

Detailed changes

go.mod πŸ”—

@@ -18,7 +18,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.9.1
 	github.com/charlievieth/fastwalk v1.0.14
-	github.com/charmbracelet/catwalk v0.10.2
+	github.com/charmbracelet/catwalk v0.11.0
 	github.com/charmbracelet/colorprofile v0.4.1
 	github.com/charmbracelet/fang v0.4.4
 	github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -53,6 +53,7 @@ require (
 	github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
 	github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
 	github.com/stretchr/testify v1.11.1
+	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.0.2
 	golang.org/x/mod v0.31.0
@@ -145,7 +146,6 @@ require (
 	github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/tetratelabs/wazero v1.10.1 // indirect
-	github.com/tidwall/gjson v1.18.0 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.1 // indirect
 	github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect

go.sum πŸ”—

@@ -92,8 +92,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
-github.com/charmbracelet/catwalk v0.10.2 h1:Ps6IeGu0ArKE3l3OYv+HwIwbnzZrAl1C3AuwXiOf1G0=
-github.com/charmbracelet/catwalk v0.10.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
+github.com/charmbracelet/catwalk v0.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg=
+github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=

internal/agent/agent.go πŸ”—

@@ -461,7 +461,17 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
 			currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
 		} else if errors.As(err, &providerErr) {
-			currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
+			if providerErr.Message == "The requested model is not supported." {
+				url := "https://github.com/settings/copilot/features"
+				link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
+				currentAssistant.AddFinish(
+					message.FinishReasonError,
+					"Copilot model not enabled",
+					fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait a minute before trying again. %s", a.largeModel.CatwalkCfg.Name, link),
+				)
+			} else {
+				currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
+			}
 		} else if errors.As(err, &fantasyErr) {
 			currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
 		} else {

internal/cmd/login.go πŸ”—

@@ -12,7 +12,9 @@ import (
 	"github.com/atotto/clipboard"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/oauth/claude"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
 	"github.com/charmbracelet/crush/internal/oauth/hyper"
 	"github.com/pkg/browser"
 	"github.com/spf13/cobra"
@@ -24,7 +26,7 @@ var loginCmd = &cobra.Command{
 	Short:   "Login Crush to a platform",
 	Long: `Login Crush to a specified platform.
 The platform should be provided as an argument.
-Available platforms are: hyper, claude.`,
+Available platforms are: hyper, claude, copilot.`,
 	Example: `
 # Authenticate with Charm Hyper
 crush login
@@ -36,6 +38,9 @@ crush login claude
 		"hyper",
 		"claude",
 		"anthropic",
+		"copilot",
+		"github",
+		"github-copilot",
 	},
 	Args: cobra.MaximumNArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
@@ -54,6 +59,8 @@ crush login claude
 			return loginHyper()
 		case "anthropic", "claude":
 			return loginClaude()
+		case "copilot", "github", "github-copilot":
+			return loginCopilot()
 		default:
 			return fmt.Errorf("unknown platform: %s", args[0])
 		}
@@ -125,8 +132,13 @@ func loginHyper() error {
 }
 
 func loginClaude() error {
-	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
-	defer cancel()
+	ctx := getLoginContext()
+
+	cfg := config.Get()
+	if cfg.HasConfigField("providers.anthropic.oauth") {
+		fmt.Println("You are already logged in to Claude.")
+		return nil
+	}
 
 	verifier, challenge, err := claude.GetChallenge()
 	if err != nil {
@@ -161,7 +173,6 @@ func loginClaude() error {
 		return err
 	}
 
-	cfg := config.Get()
 	if err := cmp.Or(
 		cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
 		cfg.SetConfigField("providers.anthropic.oauth", token),
@@ -174,6 +185,72 @@ func loginClaude() error {
 	return nil
 }
 
+func loginCopilot() error {
+	ctx := getLoginContext()
+
+	cfg := config.Get()
+	if cfg.HasConfigField("providers.copilot.oauth") {
+		fmt.Println("You are already logged in to GitHub Copilot.")
+		return nil
+	}
+
+	diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
+	var token *oauth.Token
+
+	switch {
+	case hasDiskToken:
+		fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
+
+		t, err := copilot.RefreshToken(ctx, diskToken)
+		if err != nil {
+			return fmt.Errorf("unable to refresh token from disk: %w", err)
+		}
+		token = t
+	default:
+		fmt.Println("Requesting device code from GitHub...")
+		dc, err := copilot.RequestDeviceCode(ctx)
+		if err != nil {
+			return err
+		}
+
+		fmt.Println()
+		fmt.Println("Open the following URL and follow the instructions to authenticate with GitHub Copilot:")
+		fmt.Println()
+		fmt.Println(lipgloss.NewStyle().Hyperlink(dc.VerificationURI, "id=copilot").Render(dc.VerificationURI))
+		fmt.Println()
+		fmt.Println("Code:", lipgloss.NewStyle().Bold(true).Render(dc.UserCode))
+		fmt.Println()
+		fmt.Println("Waiting for authorization...")
+
+		t, err := copilot.PollForToken(ctx, dc)
+		if err != nil {
+			return err
+		}
+		token = t
+	}
+
+	if err := cmp.Or(
+		cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
+		cfg.SetConfigField("providers.copilot.oauth", token),
+	); err != nil {
+		return err
+	}
+
+	fmt.Println()
+	fmt.Println("You're now authenticated with GitHub Copilot!")
+	return nil
+}
+
+func getLoginContext() context.Context {
+	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
+	go func() {
+		<-ctx.Done()
+		cancel()
+		os.Exit(1)
+	}()
+	return ctx
+}
+
 func waitEnter() {
 	_, _ = fmt.Scanln()
 }

internal/config/config.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
+	"maps"
 	"net/http"
 	"net/url"
 	"os"
@@ -18,8 +19,10 @@ import (
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/oauth/claude"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
 	"github.com/charmbracelet/crush/internal/oauth/hyper"
 	"github.com/invopop/jsonschema"
+	"github.com/tidwall/gjson"
 	"github.com/tidwall/sjson"
 )
 
@@ -136,6 +139,10 @@ func (pc *ProviderConfig) SetupClaudeCode() {
 	pc.ExtraHeaders["anthropic-beta"] = value
 }
 
+func (pc *ProviderConfig) SetupGitHubCopilot() {
+	maps.Copy(pc.ExtraHeaders, copilot.Headers())
+}
+
 type MCPType string
 
 const (
@@ -452,6 +459,14 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte
 	return nil
 }
 
+func (c *Config) HasConfigField(key string) bool {
+	data, err := os.ReadFile(c.dataConfigDir)
+	if err != nil {
+		return false
+	}
+	return gjson.Get(string(data), key).Exists()
+}
+
 func (c *Config) SetConfigField(key string, value any) error {
 	// read the data
 	data, err := os.ReadFile(c.dataConfigDir)
@@ -485,23 +500,32 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
 	}
 
 	var newToken *oauth.Token
-	var err error
+	var refreshErr error
 	switch providerID {
 	case string(catwalk.InferenceProviderAnthropic):
-		newToken, err = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
+		newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
+	case string(catwalk.InferenceProviderCopilot):
+		newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
 	case hyperp.Name:
-		newToken, err = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
+		newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
 	default:
 		return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
 	}
-	if err != nil {
-		return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
+	if refreshErr != nil {
+		return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
 	}
 
 	slog.Info("Successfully refreshed OAuth token", "provider", providerID)
 	providerConfig.OAuthToken = newToken
-	providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
-	providerConfig.SetupClaudeCode()
+
+	switch providerID {
+	case string(catwalk.InferenceProviderAnthropic):
+		providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
+		providerConfig.SetupClaudeCode()
+	case string(catwalk.InferenceProviderCopilot):
+		providerConfig.APIKey = newToken.AccessToken
+		providerConfig.SetupGitHubCopilot()
+	}
 
 	c.Providers.Set(providerID, providerConfig)
 

internal/config/copilot.go πŸ”—

@@ -0,0 +1,43 @@
+package config
+
+import (
+	"cmp"
+	"context"
+	"log/slog"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
+)
+
+func (c *Config) importCopilot() (*oauth.Token, bool) {
+	if testing.Testing() {
+		return nil, false
+	}
+
+	if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") {
+		return nil, false
+	}
+
+	diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
+	if !hasDiskToken {
+		return nil, false
+	}
+
+	slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
+	token, err := copilot.RefreshToken(context.TODO(), diskToken)
+	if err != nil {
+		slog.Error("Unable to import GitHub Copilot token", "error", err)
+		return nil, false
+	}
+
+	if err := cmp.Or(
+		c.SetConfigField("providers.copilot.api_key", token.AccessToken),
+		c.SetConfigField("providers.copilot.oauth", token),
+	); err != nil {
+		slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
+	}
+
+	slog.Info("GitHub Copilot successfully imported")
+	return token, true
+}

internal/config/load.go πŸ”—

@@ -132,6 +132,8 @@ func PushPopCrushEnv() func() {
 }
 
 func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
+	c.importCopilot()
+
 	knownProviderNames := make(map[string]bool)
 	restore := PushPopCrushEnv()
 	defer restore()
@@ -199,8 +201,18 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 			Models:             p.Models,
 		}
 
-		if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
+		switch {
+		case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
 			prepared.SetupClaudeCode()
+		case p.ID == catwalk.InferenceProviderCopilot:
+			if config.OAuthToken != nil {
+				if token, ok := c.importCopilot(); ok {
+					prepared.OAuthToken = token
+				}
+			}
+			if config.OAuthToken != nil {
+				prepared.SetupGitHubCopilot()
+			}
 		}
 
 		switch p.ID {

internal/oauth/copilot/disk.go πŸ”—

@@ -0,0 +1,36 @@
+package copilot
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"runtime"
+)
+
+func RefreshTokenFromDisk() (string, bool) {
+	data, err := os.ReadFile(tokenFilePath())
+	if err != nil {
+		return "", false
+	}
+	var content map[string]struct {
+		User        string `json:"user"`
+		OAuthToken  string `json:"oauth_token"`
+		GitHubAppID string `json:"githubAppId"`
+	}
+	if err := json.Unmarshal(data, &content); err != nil {
+		return "", false
+	}
+	if app, ok := content["github.com:Iv1.b507a08c87ecfe98"]; ok {
+		return app.OAuthToken, true
+	}
+	return "", false
+}
+
+func tokenFilePath() string {
+	switch runtime.GOOS {
+	case "windows":
+		return filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot/apps.json")
+	default:
+		return filepath.Join(os.Getenv("HOME"), ".config/github-copilot/apps.json")
+	}
+}

internal/oauth/copilot/http.go πŸ”—

@@ -0,0 +1,17 @@
+package copilot
+
+const (
+	userAgent           = "GitHubCopilotChat/0.32.4"
+	editorVersion       = "vscode/1.105.1"
+	editorPluginVersion = "copilot-chat/0.32.4"
+	integrationID       = "vscode-chat"
+)
+
+func Headers() map[string]string {
+	return map[string]string{
+		"User-Agent":             userAgent,
+		"Editor-Version":         editorVersion,
+		"Editor-Plugin-Version":  editorPluginVersion,
+		"Copilot-Integration-Id": integrationID,
+	}
+}

internal/oauth/copilot/oauth.go πŸ”—

@@ -0,0 +1,199 @@
+package copilot
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/oauth"
+)
+
+const (
+	clientID = "Iv1.b507a08c87ecfe98"
+
+	deviceCodeURL   = "https://github.com/login/device/code"
+	accessTokenURL  = "https://github.com/login/oauth/access_token"
+	copilotTokenURL = "https://api.github.com/copilot_internal/v2/token"
+)
+
+type DeviceCode struct {
+	DeviceCode      string `json:"device_code"`
+	UserCode        string `json:"user_code"`
+	VerificationURI string `json:"verification_uri"`
+	ExpiresIn       int    `json:"expires_in"`
+	Interval        int    `json:"interval"`
+}
+
+// RequestDeviceCode initiates the device code flow with GitHub.
+func RequestDeviceCode(ctx context.Context) (*DeviceCode, error) {
+	data := url.Values{}
+	data.Set("client_id", clientID)
+	data.Set("scope", "read:user")
+
+	req, err := http.NewRequestWithContext(ctx, "POST", deviceCodeURL, strings.NewReader(data.Encode()))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("User-Agent", userAgent)
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		return nil, fmt.Errorf("device code request failed: %s - %s", resp.Status, string(body))
+	}
+
+	var dc DeviceCode
+	if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil {
+		return nil, err
+	}
+	return &dc, nil
+}
+
+// PollForToken polls GitHub for the access token after user authorization.
+func PollForToken(ctx context.Context, dc *DeviceCode) (*oauth.Token, error) {
+	interval := max(dc.Interval, 5)
+	deadline := time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second)
+	ticker := time.NewTicker(time.Duration(interval) * time.Second)
+	defer ticker.Stop()
+
+	for time.Now().Before(deadline) {
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-ticker.C:
+		}
+
+		token, err := tryGetToken(ctx, dc.DeviceCode)
+		if err == errPending {
+			continue
+		}
+		if err == errSlowDown {
+			interval += 5
+			ticker.Reset(time.Duration(interval) * time.Second)
+			continue
+		}
+		if err != nil {
+			return nil, err
+		}
+		return token, nil
+	}
+
+	return nil, fmt.Errorf("authorization timed out")
+}
+
+var (
+	errPending  = fmt.Errorf("pending")
+	errSlowDown = fmt.Errorf("slow_down")
+)
+
+func tryGetToken(ctx context.Context, deviceCode string) (*oauth.Token, error) {
+	data := url.Values{}
+	data.Set("client_id", clientID)
+	data.Set("device_code", deviceCode)
+	data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
+
+	req, err := http.NewRequestWithContext(ctx, "POST", accessTokenURL, strings.NewReader(data.Encode()))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("User-Agent", userAgent)
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	var result struct {
+		AccessToken string `json:"access_token"`
+		Error       string `json:"error"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+		return nil, err
+	}
+
+	switch result.Error {
+	case "":
+		if result.AccessToken == "" {
+			return nil, errPending
+		}
+		return getCopilotToken(ctx, result.AccessToken)
+	case "authorization_pending":
+		return nil, errPending
+	case "slow_down":
+		return nil, errSlowDown
+	default:
+		return nil, fmt.Errorf("authorization failed: %s", result.Error)
+	}
+}
+
+func getCopilotToken(ctx context.Context, githubToken string) (*oauth.Token, error) {
+	req, err := http.NewRequestWithContext(ctx, "GET", copilotTokenURL, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", githubToken))
+	for k, v := range Headers() {
+		req.Header.Set(k, v)
+	}
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		if resp.StatusCode == http.StatusNotFound {
+			return nil, fmt.Errorf("copilot not available for this account\n\n" +
+				"Please ensure you have GitHub Copilot enabled at:\n" +
+				"https://github.com/settings/copilot")
+		}
+		return nil, fmt.Errorf("copilot token request failed: %s - %s", resp.Status, string(body))
+	}
+
+	var result struct {
+		Token     string `json:"token"`
+		ExpiresAt int64  `json:"expires_at"`
+	}
+	if err := json.Unmarshal(body, &result); err != nil {
+		return nil, err
+	}
+
+	copilotToken := &oauth.Token{
+		AccessToken:  result.Token,
+		RefreshToken: githubToken,
+		ExpiresAt:    result.ExpiresAt,
+	}
+	copilotToken.SetExpiresIn()
+
+	return copilotToken, nil
+}
+
+// RefreshToken refreshes the Copilot token using the GitHub token.
+func RefreshToken(ctx context.Context, githubToken string) (*oauth.Token, error) {
+	return getCopilotToken(ctx, githubToken)
+}

internal/oauth/token.go πŸ”—

@@ -21,3 +21,8 @@ func (t *Token) SetExpiresAt() {
 func (t *Token) IsExpired() bool {
 	return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
 }
+
+// SetExpiresIn calculates and sets the ExpiresIn field based on the ExpiresAt field.
+func (t *Token) SetExpiresIn() {
+	t.ExpiresIn = int(time.Until(time.Unix(t.ExpiresAt, 0)).Seconds())
+}