From 5fe017723303e8cdd40f4f14de9da46c911e761f Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 17 Dec 2025 14:49:01 -0300 Subject: [PATCH] feat: add github copilot auth flow via tui --- internal/cmd/login.go | 13 + internal/config/config.go | 8 +- internal/oauth/copilot/oauth.go | 11 +- internal/oauth/copilot/urls.go | 6 + .../components/dialogs/copilot/device_flow.go | 281 ++++++++++++++++++ .../tui/components/dialogs/models/keys.go | 15 +- .../tui/components/dialogs/models/list.go | 3 +- .../tui/components/dialogs/models/models.go | 72 ++++- 8 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 internal/oauth/copilot/urls.go create mode 100644 internal/tui/components/dialogs/copilot/device_flow.go diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 899ff4ff503a916e44ebe66dd0dca90917465713..334f32c7067d6820323363ea80a8978ae9229685 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -33,6 +33,9 @@ crush login # Authenticate with Claude Code Max crush login claude + +# Authenticate with GitHub Copilot +crush login copilot `, ValidArgs: []cobra.Completion{ "hyper", @@ -223,6 +226,16 @@ func loginCopilot() error { fmt.Println("Waiting for authorization...") t, err := copilot.PollForToken(ctx, dc) + if err == copilot.ErrNotAvailable { + fmt.Println() + fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:") + fmt.Println() + fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL)) + fmt.Println() + fmt.Println("You may be able to request free access if elegible. For more information, see:") + fmt.Println() + fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL)) + } if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 41ef5ab9f24aa975f4473d634ae5e49f4faf8c31..b7717b273af3fe7497afe7e63775c19c19fcd5ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -558,10 +558,14 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error { return err } setKeyOrToken = func() { - providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken) + providerConfig.APIKey = v.AccessToken providerConfig.OAuthToken = v - if providerID == string(catwalk.InferenceProviderAnthropic) { + switch providerID { + case string(catwalk.InferenceProviderAnthropic): + providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken) providerConfig.SetupClaudeCode() + case string(catwalk.InferenceProviderCopilot): + providerConfig.SetupGitHubCopilot() } } } diff --git a/internal/oauth/copilot/oauth.go b/internal/oauth/copilot/oauth.go index 2563357beccfbd8ef273c450a66308b080799e5e..40ec7b1b9a3f1c30376aed39321c7a83a40ba03c 100644 --- a/internal/oauth/copilot/oauth.go +++ b/internal/oauth/copilot/oauth.go @@ -3,6 +3,7 @@ package copilot import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -21,6 +22,8 @@ const ( copilotTokenURL = "https://api.github.com/copilot_internal/v2/token" ) +var ErrNotAvailable = errors.New("github copilot not available") + type DeviceCode struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` @@ -166,12 +169,10 @@ func getCopilotToken(ctx context.Context, githubToken string) (*oauth.Token, err return nil, err } + if resp.StatusCode == http.StatusForbidden { + return nil, ErrNotAvailable + } 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)) } diff --git a/internal/oauth/copilot/urls.go b/internal/oauth/copilot/urls.go new file mode 100644 index 0000000000000000000000000000000000000000..a61535b4d2afa75133690574440073a6282f94a6 --- /dev/null +++ b/internal/oauth/copilot/urls.go @@ -0,0 +1,6 @@ +package copilot + +const ( + SignupURL = "https://github.com/github-copilot/signup?editor=crush" + FreeURL = "https://docs.github.com/en/copilot/how-tos/manage-your-account/get-free-access-to-copilot-pro" +) diff --git a/internal/tui/components/dialogs/copilot/device_flow.go b/internal/tui/components/dialogs/copilot/device_flow.go new file mode 100644 index 0000000000000000000000000000000000000000..d3f792291e4bce77dc5ceacb1aa1200a111981dc --- /dev/null +++ b/internal/tui/components/dialogs/copilot/device_flow.go @@ -0,0 +1,281 @@ +// Package copilot provides the dialog for Copilot device flow authentication. +package copilot + +import ( + "context" + "fmt" + "time" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/oauth/copilot" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/pkg/browser" +) + +// DeviceFlowState represents the current state of the device flow. +type DeviceFlowState int + +const ( + DeviceFlowStateDisplay DeviceFlowState = iota + DeviceFlowStateSuccess + DeviceFlowStateError + DeviceFlowStateUnavailable +) + +// DeviceAuthInitiatedMsg is sent when the device auth is initiated +// successfully. +type DeviceAuthInitiatedMsg struct { + deviceCode *copilot.DeviceCode +} + +// DeviceFlowCompletedMsg is sent when the device flow completes successfully. +type DeviceFlowCompletedMsg struct { + Token *oauth.Token +} + +// DeviceFlowErrorMsg is sent when the device flow encounters an error. +type DeviceFlowErrorMsg struct { + Error error +} + +// DeviceFlow handles the Copilot device flow authentication. +type DeviceFlow struct { + State DeviceFlowState + width int + deviceCode *copilot.DeviceCode + token *oauth.Token + cancelFunc context.CancelFunc + spinner spinner.Model +} + +// NewDeviceFlow creates a new device flow component. +func NewDeviceFlow() *DeviceFlow { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight) + return &DeviceFlow{ + State: DeviceFlowStateDisplay, + spinner: s, + } +} + +// Init initializes the device flow by calling the device auth API and starting polling. +func (d *DeviceFlow) Init() tea.Cmd { + return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth) +} + +// Update handles messages and state transitions. +func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) { + var cmd tea.Cmd + d.spinner, cmd = d.spinner.Update(msg) + + switch msg := msg.(type) { + case DeviceAuthInitiatedMsg: + return d, tea.Batch(cmd, d.startPolling(msg.deviceCode)) + case DeviceFlowCompletedMsg: + d.State = DeviceFlowStateSuccess + d.token = msg.Token + return d, nil + case DeviceFlowErrorMsg: + switch msg.Error { + case copilot.ErrNotAvailable: + d.State = DeviceFlowStateUnavailable + default: + d.State = DeviceFlowStateError + } + return d, nil + } + + return d, cmd +} + +// View renders the device flow dialog. +func (d *DeviceFlow) View() string { + t := styles.CurrentTheme() + + whiteStyle := lipgloss.NewStyle().Foreground(t.White) + primaryStyle := lipgloss.NewStyle().Foreground(t.Primary) + greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight) + linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) + errorStyle := lipgloss.NewStyle().Foreground(t.Error) + mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted) + + switch d.State { + case DeviceFlowStateDisplay: + if d.deviceCode == nil { + return lipgloss.NewStyle(). + Margin(0, 1). + Render( + greenStyle.Render(d.spinner.View()) + + mutedStyle.Render("Initializing..."), + ) + } + + instructions := lipgloss.NewStyle(). + Margin(1, 1, 0, 1). + Width(d.width - 2). + Render( + whiteStyle.Render("Press ") + + primaryStyle.Render("enter") + + whiteStyle.Render(" to copy the code below and open the browser."), + ) + + codeBox := lipgloss.NewStyle(). + Width(d.width-2). + Height(7). + Align(lipgloss.Center, lipgloss.Center). + Background(t.BgBaseLighter). + Margin(1). + Render( + lipgloss.NewStyle(). + Bold(true). + Foreground(t.White). + Render(d.deviceCode.UserCode), + ) + + uri := d.deviceCode.VerificationURI + link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri) + url := mutedStyle. + Margin(0, 1). + Width(d.width - 2). + Render("Browser not opening? Refer to\n" + link) + + waiting := greenStyle. + Width(d.width-2). + Margin(1, 1, 0, 1). + Render(d.spinner.View() + "Verifying...") + + return lipgloss.JoinVertical( + lipgloss.Left, + instructions, + codeBox, + url, + waiting, + ) + + case DeviceFlowStateSuccess: + return greenStyle.Margin(0, 1).Render("Authentication successful!") + + case DeviceFlowStateError: + return lipgloss.NewStyle(). + Margin(0, 1). + Width(d.width - 2). + Render(errorStyle.Render("Authentication failed.")) + + case DeviceFlowStateUnavailable: + message := lipgloss.NewStyle(). + Margin(0, 1). + Width(d.width - 2). + Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:") + freeMessage := lipgloss.NewStyle(). + Margin(0, 1). + Width(d.width - 2). + Render("You may be able to request free access if elegible. For more information, see:") + return lipgloss.JoinVertical( + lipgloss.Left, + message, + "", + linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL), + "", + freeMessage, + "", + linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL), + ) + + default: + return "" + } +} + +// SetWidth sets the width of the dialog. +func (d *DeviceFlow) SetWidth(w int) { + d.width = w +} + +// Cursor hides the cursor. +func (d *DeviceFlow) Cursor() *tea.Cursor { return nil } + +// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL. +func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd { + switch d.State { + case DeviceFlowStateDisplay: + return tea.Sequence( + tea.SetClipboard(d.deviceCode.UserCode), + func() tea.Msg { + if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil { + return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} + } + return nil + }, + util.ReportInfo("Code copied and URL opened"), + ) + case DeviceFlowStateUnavailable: + return tea.Sequence( + func() tea.Msg { + if err := browser.OpenURL(copilot.SignupURL); err != nil { + return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)} + } + return nil + }, + util.ReportInfo("Code copied and URL opened"), + ) + default: + return nil + } +} + +// CopyCode copies just the user code to the clipboard. +func (d *DeviceFlow) CopyCode() tea.Cmd { + if d.State != DeviceFlowStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.deviceCode.UserCode), + util.ReportInfo("Code copied to clipboard"), + ) +} + +// Cancel cancels the device flow polling. +func (d *DeviceFlow) Cancel() { + if d.cancelFunc != nil { + d.cancelFunc() + } +} + +func (d *DeviceFlow) initiateDeviceAuth() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + deviceCode, err := copilot.RequestDeviceCode(ctx) + if err != nil { + return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)} + } + + d.deviceCode = deviceCode + + return DeviceAuthInitiatedMsg{ + deviceCode: d.deviceCode, + } +} + +// startPolling starts polling for the device token. +func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + d.cancelFunc = cancel + + token, err := copilot.PollForToken(ctx, deviceCode) + if err != nil { + if ctx.Err() != nil { + return nil // cancelled, don't report error. + } + return DeviceFlowErrorMsg{Error: err} + } + + return DeviceFlowCompletedMsg{Token: token} + } +} diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index b0d737f6e205c260c33d758c43ec5ad210f8db3f..eda235aebb858fef21c582921cfb9e305a6fed19 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/internal/tui/components/dialogs/models/keys.go @@ -15,7 +15,9 @@ type KeyMap struct { isAPIKeyHelp bool isAPIKeyValid bool - isHyperDeviceFlow bool + isHyperDeviceFlow bool + isCopilotDeviceFlow bool + isCopilotUnavailable bool isClaudeAuthChoiceHelp bool isClaudeOAuthHelp bool @@ -76,7 +78,7 @@ func (k KeyMap) FullHelp() [][]key.Binding { // ShortHelp implements help.KeyMap. func (k KeyMap) ShortHelp() []key.Binding { - if k.isHyperDeviceFlow { + if k.isHyperDeviceFlow || k.isCopilotDeviceFlow { return []key.Binding{ key.NewBinding( key.WithKeys("c"), @@ -89,6 +91,15 @@ func (k KeyMap) ShortHelp() []key.Binding { k.Close, } } + if k.isCopilotUnavailable { + return []key.Binding{ + key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "open signup"), + ), + k.Close, + } + } if k.isClaudeAuthChoiceHelp { return []key.Binding{ key.NewBinding( diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index eb0ad1b983f86d892c38300ae79296b73a392da8..82bc2d4ab786492b10a056a2eb65e9d3ac61bd8f 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -63,7 +63,8 @@ func (m *ModelListComponent) Init() tea.Cmd { for _, p := range providers { hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$") isHyper := p.ID == "hyper" - if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper { + isCopilot := p.ID == catwalk.InferenceProviderCopilot + if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot { filteredProviders = append(filteredProviders, p) } } diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 0fb3dcabfa7e3a42068e5540dc7bd4edaee3c2db..8ed2ffbf0bf0ddd4641fbbda6f0e2b20a1967e07 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" @@ -77,6 +78,10 @@ type modelDialogCmp struct { hyperDeviceFlow *hyper.DeviceFlow showHyperDeviceFlow bool + // Copilot device flow state + copilotDeviceFlow *copilot.DeviceFlow + showCopilotDeviceFlow bool + // Claude state claudeAuthMethodChooser *claude.AuthMethodChooser claudeOAuth2 *claude.OAuth2 @@ -143,6 +148,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, cmd } return m, nil + case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg: + if m.copilotDeviceFlow != nil { + u, cmd := m.copilotDeviceFlow.Update(msg) + m.copilotDeviceFlow = u.(*copilot.DeviceFlow) + return m, cmd + } + return m, nil + case copilot.DeviceFlowCompletedMsg: + return m, m.saveOauthTokenAndContinue(msg.Token, true) case claude.ValidationCompletedMsg: var cmds []tea.Cmd u, cmd := m.claudeOAuth2.Update(msg) @@ -160,10 +174,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { case tea.KeyPressMsg: switch { // Handle Hyper device flow keys - case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow: + case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && (m.showHyperDeviceFlow || m.showCopilotDeviceFlow): if m.hyperDeviceFlow != nil { return m, m.hyperDeviceFlow.CopyCode() } + if m.copilotDeviceFlow != nil { + return m, m.copilotDeviceFlow.CopyCode() + } case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL: return m, tea.Sequence( tea.SetClipboard(m.claudeOAuth2.URL), @@ -181,6 +198,9 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { return m, m.hyperDeviceFlow.CopyCodeAndOpenURL() } + if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { + return m, m.copilotDeviceFlow.CopyCodeAndOpenURL() + } selectedItem := m.modelList.SelectedModel() modelType := config.SelectedModelTypeLarge @@ -193,6 +213,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.keyMap.isClaudeOAuthHelp = false m.keyMap.isAPIKeyHelp = true m.showHyperDeviceFlow = false + m.showCopilotDeviceFlow = false m.showClaudeAuthMethodChooser = false m.needsAPIKey = true m.selectedModel = selectedItem @@ -288,6 +309,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.hyperDeviceFlow = hyper.NewDeviceFlow() m.hyperDeviceFlow.SetWidth(m.width - 2) return m, m.hyperDeviceFlow.Init() + case catwalk.InferenceProviderCopilot: + m.showCopilotDeviceFlow = true + m.selectedModel = selectedItem + m.selectedModelType = modelType + m.copilotDeviceFlow = copilot.NewDeviceFlow() + m.copilotDeviceFlow.SetWidth(m.width - 2) + return m, m.copilotDeviceFlow.Init() } // For other providers, show API key input askForApiKey() @@ -310,13 +338,19 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case key.Matches(msg, m.keyMap.Close): if m.showHyperDeviceFlow { - // Cancel device flow and go back to model selection if m.hyperDeviceFlow != nil { m.hyperDeviceFlow.Cancel() } m.showHyperDeviceFlow = false m.selectedModel = nil } + if m.showCopilotDeviceFlow { + if m.copilotDeviceFlow != nil { + m.copilotDeviceFlow.Cancel() + } + m.showCopilotDeviceFlow = false + m.selectedModel = nil + } if m.showClaudeAuthMethodChooser { m.claudeAuthMethodChooser.SetDefaults() m.showClaudeAuthMethodChooser = false @@ -377,18 +411,27 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { u, cmd = m.hyperDeviceFlow.Update(msg) m.hyperDeviceFlow = u.(*hyper.DeviceFlow) } + if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { + u, cmd = m.copilotDeviceFlow.Update(msg) + m.copilotDeviceFlow = u.(*copilot.DeviceFlow) + } return m, cmd default: // Pass all other messages to the device flow for spinner animation - if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { + switch { + case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil: u, cmd := m.hyperDeviceFlow.Update(msg) m.hyperDeviceFlow = u.(*hyper.DeviceFlow) return m, cmd - } else if m.showClaudeOAuth2 { + case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil: + u, cmd := m.copilotDeviceFlow.Update(msg) + m.copilotDeviceFlow = u.(*copilot.DeviceFlow) + return m, cmd + case m.showClaudeOAuth2: u, cmd := m.claudeOAuth2.Update(msg) m.claudeOAuth2 = u.(*claude.OAuth2) return m, cmd - } else { + default: u, cmd := m.apiKeyInput.Update(msg) m.apiKeyInput = u.(*APIKeyInput) return m, cmd @@ -413,9 +456,25 @@ func (m *modelDialogCmp) View() string { ) return m.style().Render(content) } + if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { + // Show Hyper device flow + m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable + m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable + deviceFlowView := m.copilotDeviceFlow.View() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)), + deviceFlowView, + "", + t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), + ) + return m.style().Render(content) + } // Reset the flags when not showing device flow m.keyMap.isHyperDeviceFlow = false + m.keyMap.isCopilotDeviceFlow = false + m.keyMap.isCopilotUnavailable = false switch { case m.showClaudeAuthMethodChooser: @@ -472,6 +531,9 @@ func (m *modelDialogCmp) Cursor() *tea.Cursor { if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { return m.hyperDeviceFlow.Cursor() } + if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { + return m.copilotDeviceFlow.Cursor() + } if m.showClaudeAuthMethodChooser { return nil }