Detailed changes
@@ -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
@@ -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=
@@ -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 {
@@ -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()
}
@@ -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)
@@ -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
+}
@@ -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 {
@@ -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")
+ }
+}
@@ -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,
+ }
+}
@@ -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)
+}
@@ -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())
+}