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