on main help sign in
feat: add github copilot support

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

commitab057fdf04affbe4454d35741684277362a92095 authorAndrey Nering <andreynering@users.noreply.github.com> dateTue 16 Dec 2025, 16:46 (1 year ago) parente67c16e
11 files changed+440−17
go.mod+2−2 go.sum+2−2 internal/agent/agent.go+11−1 internal/cmd/login.go+81−4 internal/config/config.go+31−7 internal/config/copilot.go+43 internal/config/load.go+13−1 internal/oauth/copilot/disk.go+36 internal/oauth/copilot/http.go+17 internal/oauth/copilot/oauth.go+199 internal/oauth/token.go+5
go.mod+2−2
@@ -18,7 +18,7 @@ require (
1818 github.com/aymanbagabas/go-udiff v0.3.1
1919 github.com/bmatcuk/doublestar/v4 v4.9.1
2020 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.10.2
21+ github.com/charmbracelet/catwalk v0.11.0
2222 github.com/charmbracelet/colorprofile v0.4.1
2323 github.com/charmbracelet/fang v0.4.4
2424 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -53,6 +53,7 @@ require (
5353 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
5454 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
5555 github.com/stretchr/testify v1.11.1
56+ github.com/tidwall/gjson v1.18.0
5657 github.com/tidwall/sjson v1.2.5
5758 github.com/zeebo/xxh3 v1.0.2
5859 golang.org/x/mod v0.31.0
@@ -145,7 +146,6 @@ require (
145146 github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
146147 github.com/spf13/pflag v1.0.9 // indirect
147148 github.com/tetratelabs/wazero v1.10.1 // indirect
148− github.com/tidwall/gjson v1.18.0 // indirect
149149 github.com/tidwall/match v1.1.1 // indirect
150150 github.com/tidwall/pretty v1.2.1 // indirect
151151 github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect
go.sum+2−2
@@ -92,8 +92,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
9292github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
9393github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
9494github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
95−github.com/charmbracelet/catwalk v0.10.2 h1:Ps6IeGu0ArKE3l3OYv+HwIwbnzZrAl1C3AuwXiOf1G0=
96−github.com/charmbracelet/catwalk v0.10.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
95+github.com/charmbracelet/catwalk v0.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg=
96+github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
9797github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
9898github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
9999github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
internal/agent/agent.go+11−1
@@ -461,7 +461,17 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
461461 link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
462462 currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
463463 } else if errors.As(err, &providerErr) {
464− currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
464+ if providerErr.Message == "The requested model is not supported." {
465+ url := "https://github.com/settings/copilot/features"
466+ link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
467+ currentAssistant.AddFinish(
468+ message.FinishReasonError,
469+ "Copilot model not enabled",
470+ 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),
471+ )
472+ } else {
473+ currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
474+ }
465475 } else if errors.As(err, &fantasyErr) {
466476 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
467477 } else {
internal/cmd/login.go+81−4
@@ -12,7 +12,9 @@ import (
1212 "github.com/atotto/clipboard"
1313 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1414 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/oauth"
1516 "github.com/charmbracelet/crush/internal/oauth/claude"
17+ "github.com/charmbracelet/crush/internal/oauth/copilot"
1618 "github.com/charmbracelet/crush/internal/oauth/hyper"
1719 "github.com/pkg/browser"
1820 "github.com/spf13/cobra"
@@ -24,7 +26,7 @@ var loginCmd = &cobra.Command{
2426 Short: "Login Crush to a platform",
2527 Long: `Login Crush to a specified platform.
2628The platform should be provided as an argument.
27−Available platforms are: hyper, claude.`,
29+Available platforms are: hyper, claude, copilot.`,
2830 Example: `
2931# Authenticate with Charm Hyper
3032crush login
@@ -36,6 +38,9 @@ crush login claude
3638 "hyper",
3739 "claude",
3840 "anthropic",
41+ "copilot",
42+ "github",
43+ "github-copilot",
3944 },
4045 Args: cobra.MaximumNArgs(1),
4146 RunE: func(cmd *cobra.Command, args []string) error {
@@ -54,6 +59,8 @@ crush login claude
5459 return loginHyper()
5560 case "anthropic", "claude":
5661 return loginClaude()
62+ case "copilot", "github", "github-copilot":
63+ return loginCopilot()
5764 default:
5865 return fmt.Errorf("unknown platform: %s", args[0])
5966 }
@@ -125,8 +132,13 @@ func loginHyper() error {
125132}
126133
127134func loginClaude() error {
128− ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
129− defer cancel()
135+ ctx := getLoginContext()
136+
137+ cfg := config.Get()
138+ if cfg.HasConfigField("providers.anthropic.oauth") {
139+ fmt.Println("You are already logged in to Claude.")
140+ return nil
141+ }
130142
131143 verifier, challenge, err := claude.GetChallenge()
132144 if err != nil {
@@ -161,7 +173,6 @@ func loginClaude() error {
161173 return err
162174 }
163175
164− cfg := config.Get()
165176 if err := cmp.Or(
166177 cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
167178 cfg.SetConfigField("providers.anthropic.oauth", token),
@@ -174,6 +185,72 @@ func loginClaude() error {
174185 return nil
175186}
176187
188+func loginCopilot() error {
189+ ctx := getLoginContext()
190+
191+ cfg := config.Get()
192+ if cfg.HasConfigField("providers.copilot.oauth") {
193+ fmt.Println("You are already logged in to GitHub Copilot.")
194+ return nil
195+ }
196+
197+ diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
198+ var token *oauth.Token
199+
200+ switch {
201+ case hasDiskToken:
202+ fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
203+
204+ t, err := copilot.RefreshToken(ctx, diskToken)
205+ if err != nil {
206+ return fmt.Errorf("unable to refresh token from disk: %w", err)
207+ }
208+ token = t
209+ default:
210+ fmt.Println("Requesting device code from GitHub...")
211+ dc, err := copilot.RequestDeviceCode(ctx)
212+ if err != nil {
213+ return err
214+ }
215+
216+ fmt.Println()
217+ fmt.Println("Open the following URL and follow the instructions to authenticate with GitHub Copilot:")
218+ fmt.Println()
219+ fmt.Println(lipgloss.NewStyle().Hyperlink(dc.VerificationURI, "id=copilot").Render(dc.VerificationURI))
220+ fmt.Println()
221+ fmt.Println("Code:", lipgloss.NewStyle().Bold(true).Render(dc.UserCode))
222+ fmt.Println()
223+ fmt.Println("Waiting for authorization...")
224+
225+ t, err := copilot.PollForToken(ctx, dc)
226+ if err != nil {
227+ return err
228+ }
229+ token = t
230+ }
231+
232+ if err := cmp.Or(
233+ cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
234+ cfg.SetConfigField("providers.copilot.oauth", token),
235+ ); err != nil {
236+ return err
237+ }
238+
239+ fmt.Println()
240+ fmt.Println("You're now authenticated with GitHub Copilot!")
241+ return nil
242+}
243+
244+func getLoginContext() context.Context {
245+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
246+ go func() {
247+ <-ctx.Done()
248+ cancel()
249+ os.Exit(1)
250+ }()
251+ return ctx
252+}
253+
177254func waitEnter() {
178255 _, _ = fmt.Scanln()
179256}
internal/config/config.go+31−7
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "log/slog"
8+ "maps"
89 "net/http"
910 "net/url"
1011 "os"
@@ -18,8 +19,10 @@ import (
1819 "github.com/charmbracelet/crush/internal/env"
1920 "github.com/charmbracelet/crush/internal/oauth"
2021 "github.com/charmbracelet/crush/internal/oauth/claude"
22+ "github.com/charmbracelet/crush/internal/oauth/copilot"
2123 "github.com/charmbracelet/crush/internal/oauth/hyper"
2224 "github.com/invopop/jsonschema"
25+ "github.com/tidwall/gjson"
2326 "github.com/tidwall/sjson"
2427)
2528
@@ -136,6 +139,10 @@ func (pc *ProviderConfig) SetupClaudeCode() {
136139 pc.ExtraHeaders["anthropic-beta"] = value
137140}
138141
142+func (pc *ProviderConfig) SetupGitHubCopilot() {
143+ maps.Copy(pc.ExtraHeaders, copilot.Headers())
144+}
145+
139146type MCPType string
140147
141148const (
@@ -452,6 +459,14 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte
452459 return nil
453460}
454461
462+func (c *Config) HasConfigField(key string) bool {
463+ data, err := os.ReadFile(c.dataConfigDir)
464+ if err != nil {
465+ return false
466+ }
467+ return gjson.Get(string(data), key).Exists()
468+}
469+
455470func (c *Config) SetConfigField(key string, value any) error {
456471 // read the data
457472 data, err := os.ReadFile(c.dataConfigDir)
@@ -485,23 +500,32 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
485500 }
486501
487502 var newToken *oauth.Token
488− var err error
503+ var refreshErr error
489504 switch providerID {
490505 case string(catwalk.InferenceProviderAnthropic):
491− newToken, err = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
506+ newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
507+ case string(catwalk.InferenceProviderCopilot):
508+ newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
492509 case hyperp.Name:
493− newToken, err = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
510+ newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
494511 default:
495512 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
496513 }
497− if err != nil {
498− return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
514+ if refreshErr != nil {
515+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
499516 }
500517
501518 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
502519 providerConfig.OAuthToken = newToken
503− providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
504− providerConfig.SetupClaudeCode()
520+
521+ switch providerID {
522+ case string(catwalk.InferenceProviderAnthropic):
523+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
524+ providerConfig.SetupClaudeCode()
525+ case string(catwalk.InferenceProviderCopilot):
526+ providerConfig.APIKey = newToken.AccessToken
527+ providerConfig.SetupGitHubCopilot()
528+ }
505529
506530 c.Providers.Set(providerID, providerConfig)
507531
internal/config/copilot.gonew file+43
@@ -0,0 +1,43 @@
1+package config
2+
3+import (
4+ "cmp"
5+ "context"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/charmbracelet/crush/internal/oauth"
10+ "github.com/charmbracelet/crush/internal/oauth/copilot"
11+)
12+
13+func (c *Config) importCopilot() (*oauth.Token, bool) {
14+ if testing.Testing() {
15+ return nil, false
16+ }
17+
18+ if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") {
19+ return nil, false
20+ }
21+
22+ diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
23+ if !hasDiskToken {
24+ return nil, false
25+ }
26+
27+ slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
28+ token, err := copilot.RefreshToken(context.TODO(), diskToken)
29+ if err != nil {
30+ slog.Error("Unable to import GitHub Copilot token", "error", err)
31+ return nil, false
32+ }
33+
34+ if err := cmp.Or(
35+ c.SetConfigField("providers.copilot.api_key", token.AccessToken),
36+ c.SetConfigField("providers.copilot.oauth", token),
37+ ); err != nil {
38+ slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
39+ }
40+
41+ slog.Info("GitHub Copilot successfully imported")
42+ return token, true
43+}
internal/config/load.go+13−1
@@ -132,6 +132,8 @@ func PushPopCrushEnv() func() {
132132}
133133
134134func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
135+ c.importCopilot()
136+
135137 knownProviderNames := make(map[string]bool)
136138 restore := PushPopCrushEnv()
137139 defer restore()
@@ -199,8 +201,18 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
199201 Models: p.Models,
200202 }
201203
202− if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
204+ switch {
205+ case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
203206 prepared.SetupClaudeCode()
207+ case p.ID == catwalk.InferenceProviderCopilot:
208+ if config.OAuthToken != nil {
209+ if token, ok := c.importCopilot(); ok {
210+ prepared.OAuthToken = token
211+ }
212+ }
213+ if config.OAuthToken != nil {
214+ prepared.SetupGitHubCopilot()
215+ }
204216 }
205217
206218 switch p.ID {
internal/oauth/copilot/disk.gonew file+36
@@ -0,0 +1,36 @@
1+package copilot
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+)
9+
10+func RefreshTokenFromDisk() (string, bool) {
11+ data, err := os.ReadFile(tokenFilePath())
12+ if err != nil {
13+ return "", false
14+ }
15+ var content map[string]struct {
16+ User string `json:"user"`
17+ OAuthToken string `json:"oauth_token"`
18+ GitHubAppID string `json:"githubAppId"`
19+ }
20+ if err := json.Unmarshal(data, &content); err != nil {
21+ return "", false
22+ }
23+ if app, ok := content["github.com:Iv1.b507a08c87ecfe98"]; ok {
24+ return app.OAuthToken, true
25+ }
26+ return "", false
27+}
28+
29+func tokenFilePath() string {
30+ switch runtime.GOOS {
31+ case "windows":
32+ return filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot/apps.json")
33+ default:
34+ return filepath.Join(os.Getenv("HOME"), ".config/github-copilot/apps.json")
35+ }
36+}
internal/oauth/copilot/http.gonew file+17
@@ -0,0 +1,17 @@
1+package copilot
2+
3+const (
4+ userAgent = "GitHubCopilotChat/0.32.4"
5+ editorVersion = "vscode/1.105.1"
6+ editorPluginVersion = "copilot-chat/0.32.4"
7+ integrationID = "vscode-chat"
8+)
9+
10+func Headers() map[string]string {
11+ return map[string]string{
12+ "User-Agent": userAgent,
13+ "Editor-Version": editorVersion,
14+ "Editor-Plugin-Version": editorPluginVersion,
15+ "Copilot-Integration-Id": integrationID,
16+ }
17+}
internal/oauth/copilot/oauth.gonew file+199
@@ -0,0 +1,199 @@
1+package copilot
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+ "time"
12+
13+ "github.com/charmbracelet/crush/internal/oauth"
14+)
15+
16+const (
17+ clientID = "Iv1.b507a08c87ecfe98"
18+
19+ deviceCodeURL = "https://github.com/login/device/code"
20+ accessTokenURL = "https://github.com/login/oauth/access_token"
21+ copilotTokenURL = "https://api.github.com/copilot_internal/v2/token"
22+)
23+
24+type DeviceCode struct {
25+ DeviceCode string `json:"device_code"`
26+ UserCode string `json:"user_code"`
27+ VerificationURI string `json:"verification_uri"`
28+ ExpiresIn int `json:"expires_in"`
29+ Interval int `json:"interval"`
30+}
31+
32+// RequestDeviceCode initiates the device code flow with GitHub.
33+func RequestDeviceCode(ctx context.Context) (*DeviceCode, error) {
34+ data := url.Values{}
35+ data.Set("client_id", clientID)
36+ data.Set("scope", "read:user")
37+
38+ req, err := http.NewRequestWithContext(ctx, "POST", deviceCodeURL, strings.NewReader(data.Encode()))
39+ if err != nil {
40+ return nil, err
41+ }
42+ req.Header.Set("Accept", "application/json")
43+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
44+ req.Header.Set("User-Agent", userAgent)
45+
46+ client := &http.Client{Timeout: 30 * time.Second}
47+ resp, err := client.Do(req)
48+ if err != nil {
49+ return nil, err
50+ }
51+ defer resp.Body.Close()
52+
53+ if resp.StatusCode != http.StatusOK {
54+ body, _ := io.ReadAll(resp.Body)
55+ return nil, fmt.Errorf("device code request failed: %s - %s", resp.Status, string(body))
56+ }
57+
58+ var dc DeviceCode
59+ if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil {
60+ return nil, err
61+ }
62+ return &dc, nil
63+}
64+
65+// PollForToken polls GitHub for the access token after user authorization.
66+func PollForToken(ctx context.Context, dc *DeviceCode) (*oauth.Token, error) {
67+ interval := max(dc.Interval, 5)
68+ deadline := time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second)
69+ ticker := time.NewTicker(time.Duration(interval) * time.Second)
70+ defer ticker.Stop()
71+
72+ for time.Now().Before(deadline) {
73+ select {
74+ case <-ctx.Done():
75+ return nil, ctx.Err()
76+ case <-ticker.C:
77+ }
78+
79+ token, err := tryGetToken(ctx, dc.DeviceCode)
80+ if err == errPending {
81+ continue
82+ }
83+ if err == errSlowDown {
84+ interval += 5
85+ ticker.Reset(time.Duration(interval) * time.Second)
86+ continue
87+ }
88+ if err != nil {
89+ return nil, err
90+ }
91+ return token, nil
92+ }
93+
94+ return nil, fmt.Errorf("authorization timed out")
95+}
96+
97+var (
98+ errPending = fmt.Errorf("pending")
99+ errSlowDown = fmt.Errorf("slow_down")
100+)
101+
102+func tryGetToken(ctx context.Context, deviceCode string) (*oauth.Token, error) {
103+ data := url.Values{}
104+ data.Set("client_id", clientID)
105+ data.Set("device_code", deviceCode)
106+ data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
107+
108+ req, err := http.NewRequestWithContext(ctx, "POST", accessTokenURL, strings.NewReader(data.Encode()))
109+ if err != nil {
110+ return nil, err
111+ }
112+ req.Header.Set("Accept", "application/json")
113+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
114+ req.Header.Set("User-Agent", userAgent)
115+
116+ client := &http.Client{Timeout: 30 * time.Second}
117+ resp, err := client.Do(req)
118+ if err != nil {
119+ return nil, err
120+ }
121+ defer resp.Body.Close()
122+
123+ var result struct {
124+ AccessToken string `json:"access_token"`
125+ Error string `json:"error"`
126+ }
127+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
128+ return nil, err
129+ }
130+
131+ switch result.Error {
132+ case "":
133+ if result.AccessToken == "" {
134+ return nil, errPending
135+ }
136+ return getCopilotToken(ctx, result.AccessToken)
137+ case "authorization_pending":
138+ return nil, errPending
139+ case "slow_down":
140+ return nil, errSlowDown
141+ default:
142+ return nil, fmt.Errorf("authorization failed: %s", result.Error)
143+ }
144+}
145+
146+func getCopilotToken(ctx context.Context, githubToken string) (*oauth.Token, error) {
147+ req, err := http.NewRequestWithContext(ctx, "GET", copilotTokenURL, nil)
148+ if err != nil {
149+ return nil, err
150+ }
151+ req.Header.Set("Accept", "application/json")
152+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", githubToken))
153+ for k, v := range Headers() {
154+ req.Header.Set(k, v)
155+ }
156+
157+ client := &http.Client{Timeout: 30 * time.Second}
158+ resp, err := client.Do(req)
159+ if err != nil {
160+ return nil, err
161+ }
162+ defer resp.Body.Close()
163+
164+ body, err := io.ReadAll(resp.Body)
165+ if err != nil {
166+ return nil, err
167+ }
168+
169+ if resp.StatusCode != http.StatusOK {
170+ if resp.StatusCode == http.StatusNotFound {
171+ return nil, fmt.Errorf("copilot not available for this account\n\n" +
172+ "Please ensure you have GitHub Copilot enabled at:\n" +
173+ "https://github.com/settings/copilot")
174+ }
175+ return nil, fmt.Errorf("copilot token request failed: %s - %s", resp.Status, string(body))
176+ }
177+
178+ var result struct {
179+ Token string `json:"token"`
180+ ExpiresAt int64 `json:"expires_at"`
181+ }
182+ if err := json.Unmarshal(body, &result); err != nil {
183+ return nil, err
184+ }
185+
186+ copilotToken := &oauth.Token{
187+ AccessToken: result.Token,
188+ RefreshToken: githubToken,
189+ ExpiresAt: result.ExpiresAt,
190+ }
191+ copilotToken.SetExpiresIn()
192+
193+ return copilotToken, nil
194+}
195+
196+// RefreshToken refreshes the Copilot token using the GitHub token.
197+func RefreshToken(ctx context.Context, githubToken string) (*oauth.Token, error) {
198+ return getCopilotToken(ctx, githubToken)
199+}
internal/oauth/token.go+5
@@ -21,3 +21,8 @@ func (t *Token) SetExpiresAt() {
2121func (t *Token) IsExpired() bool {
2222 return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
2323}
24+
25+// SetExpiresIn calculates and sets the ExpiresIn field based on the ExpiresAt field.
26+func (t *Token) SetExpiresIn() {
27+ t.ExpiresIn = int(time.Until(time.Unix(t.ExpiresAt, 0)).Seconds())
28+}