devel/crush: new port, crush 0.82.0

This commit is contained in:
c0dev0id
2026-07-08 11:07:34 +02:00
parent bdaeed24db
commit 6d238cb67e
13 changed files with 4098 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
--- internal/cmd/login.go
+++ internal/cmd/login.go
@@ -6,12 +6,14 @@
"fmt"
"os"
"os/signal"
+ "strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/client"
"github.com/charmbracelet/crush/internal/clipboard"
"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/charmbracelet/x/ansi"
@@ -25,11 +27,14 @@
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, copilot.`,
+Available platforms are: hyper, claude, copilot.`,
Example: `
# Authenticate with Charm Hyper
crush login
+# Authenticate with Claude Code Max
+crush login claude
+
# Authenticate with GitHub Copilot
crush login copilot
@@ -38,6 +43,8 @@
`,
ValidArgs: []cobra.Completion{
"hyper",
+ "claude",
+ "anthropic",
"copilot",
"github",
"github-copilot",
@@ -64,6 +71,8 @@
switch provider {
case "hyper":
return loginHyper(c, ws.ID, force)
+ case "anthropic", "claude":
+ return loginClaude(c, ws.ID, force)
case "copilot", "github", "github-copilot":
return loginCopilot(c, ws.ID, force)
default:
@@ -140,6 +149,68 @@
fmt.Println()
fmt.Println("You're now authenticated with Hyper!")
+ return nil
+}
+
+func loginClaude(c *client.Client, wsID string, force bool) error {
+ ctx := getLoginContext()
+
+ if !force {
+ cfg, err := c.GetConfig(ctx, wsID)
+ if err == nil && cfg != nil {
+ if pc, ok := cfg.Providers.Get("anthropic"); ok && pc.OAuthToken != nil {
+ fmt.Println("You are already logged in to Claude Code Max.")
+ fmt.Println("Use --force to re-authenticate.")
+ return nil
+ }
+ }
+ }
+
+ verifier, challenge, err := claude.GetChallenge()
+ if err != nil {
+ return err
+ }
+ authURL, err := claude.AuthorizeURL(verifier, challenge)
+ if err != nil {
+ return err
+ }
+
+ fmt.Println("Open the following URL to authorize your Claude Code Max subscription:")
+ fmt.Println()
+ fmt.Println(lipgloss.NewStyle().Hyperlink(authURL, "id=claude").Render(authURL))
+ fmt.Println()
+ fmt.Println("Press enter to open in your browser...")
+ waitEnter()
+ if err := browser.OpenURL(authURL); err != nil {
+ fmt.Println("Could not open the URL. Open it manually in your browser.")
+ }
+
+ fmt.Println()
+ fmt.Println("After authorizing, paste the code shown on the page and press enter:")
+ fmt.Println()
+ fmt.Print("> ")
+ var code string
+ for code == "" {
+ _, _ = fmt.Scan(&code)
+ code = strings.TrimSpace(code)
+ }
+
+ fmt.Println()
+ fmt.Println("Exchanging authorization code...")
+ token, err := claude.ExchangeToken(ctx, code, verifier)
+ if err != nil {
+ return err
+ }
+
+ if err := cmp.Or(
+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.anthropic.api_key", token.AccessToken),
+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.anthropic.oauth", token),
+ ); err != nil {
+ return err
+ }
+
+ fmt.Println()
+ fmt.Println("You're now authenticated with Claude Code Max!")
return nil
}

View File

@@ -0,0 +1,23 @@
--- internal/config/config.go
+++ internal/config/config.go
@@ -177,6 +177,22 @@ func (c *ProviderConfig) SetupGitHubCopilot() {
maps.Copy(c.ExtraHeaders, copilot.Headers())
}
+func (c *ProviderConfig) SetupClaudeCode() {
+ c.FlatRate = true
+ c.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
+ if c.ExtraHeaders == nil {
+ c.ExtraHeaders = make(map[string]string)
+ }
+ c.ExtraHeaders["anthropic-version"] = "2023-06-01"
+ const want = "oauth-2025-04-20"
+ if v := c.ExtraHeaders["anthropic-beta"]; !strings.Contains(v, want) {
+ if v != "" {
+ v += ","
+ }
+ c.ExtraHeaders["anthropic-beta"] = v + want
+ }
+}
+
type MCPType string

View File

@@ -0,0 +1,18 @@
--- internal/config/load.go
+++ internal/config/load.go
@@ -287,14 +287,7 @@
switch {
case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
- // Claude Code subscription is not supported anymore. Remove to show onboarding.
- // RemoveConfigField persists the deletion to disk. The in-memory
- // state is kept consistent by the Providers.Del call below; any
- // concurrent reload that races with this write will also see the
- // removal because it re-reads from disk.
- store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
- c.Providers.Del(string(p.ID))
- continue
+ prepared.SetupClaudeCode()
case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
prepared.SetupGitHubCopilot()
}

View File

@@ -0,0 +1,52 @@
--- internal/config/store.go
+++ internal/config/store.go
@@ -16,6 +16,7 @@
"github.com/charmbracelet/crush/internal/env"
"github.com/charmbracelet/crush/internal/lock"
"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/tidwall/gjson"
@@ -470,6 +471,8 @@
providerConfig.APIKey = v.AccessToken
providerConfig.OAuthToken = v
switch providerID {
+ case string(catwalk.InferenceProviderAnthropic):
+ providerConfig.SetupClaudeCode()
case string(catwalk.InferenceProviderCopilot):
providerConfig.SetupGitHubCopilot()
}
@@ -596,7 +599,10 @@
slog.Info("Successfully refreshed OAuth token", "provider", providerID)
providerConfig.OAuthToken = refreshedToken
providerConfig.APIKey = refreshedToken.AccessToken
- if providerID == string(catwalk.InferenceProviderCopilot) {
+ switch providerID {
+ case string(catwalk.InferenceProviderAnthropic):
+ providerConfig.SetupClaudeCode()
+ case string(catwalk.InferenceProviderCopilot):
providerConfig.SetupGitHubCopilot()
}
cfg.Providers.Set(providerID, providerConfig)
@@ -638,6 +644,8 @@
return s.exchangeToken(ctx, providerID, refreshToken)
}
switch providerID {
+ case string(catwalk.InferenceProviderAnthropic):
+ return claude.RefreshToken(ctx, refreshToken)
case string(catwalk.InferenceProviderCopilot):
return copilot.RefreshToken(ctx, refreshToken)
case hyperp.Name:
@@ -662,7 +670,10 @@
func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
providerConfig.OAuthToken = token
providerConfig.APIKey = token.AccessToken
- if providerID == string(catwalk.InferenceProviderCopilot) {
+ switch providerID {
+ case string(catwalk.InferenceProviderAnthropic):
+ providerConfig.SetupClaudeCode()
+ case string(catwalk.InferenceProviderCopilot):
providerConfig.SetupGitHubCopilot()
}
s.Config().Providers.Set(providerID, providerConfig)

View File

@@ -0,0 +1,31 @@
--- /dev/null
+++ internal/oauth/claude/challenge.go
@@ -0,0 +1,28 @@
+package claude
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "strings"
+)
+
+// GetChallenge generates a PKCE verifier and its corresponding challenge.
+func GetChallenge() (verifier string, challenge string, err error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", "", err
+ }
+ verifier = encodeBase64(bytes)
+ hash := sha256.Sum256([]byte(verifier))
+ challenge = encodeBase64(hash[:])
+ return verifier, challenge, nil
+}
+
+func encodeBase64(input []byte) (encoded string) {
+ encoded = base64.StdEncoding.EncodeToString(input)
+ encoded = strings.ReplaceAll(encoded, "=", "")
+ encoded = strings.ReplaceAll(encoded, "+", "-")
+ encoded = strings.ReplaceAll(encoded, "/", "_")
+ return encoded
+}

View File

@@ -0,0 +1,129 @@
--- /dev/null
+++ internal/oauth/claude/oauth.go
@@ -0,0 +1,126 @@
+package claude
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/oauth"
+)
+
+const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
+
+// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
+func AuthorizeURL(verifier, challenge string) (string, error) {
+ u, err := url.Parse("https://claude.ai/oauth/authorize")
+ if err != nil {
+ return "", err
+ }
+ q := u.Query()
+ q.Set("response_type", "code")
+ q.Set("client_id", clientId)
+ q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
+ q.Set("scope", "org:create_api_key user:profile user:inference")
+ q.Set("code_challenge", challenge)
+ q.Set("code_challenge_method", "S256")
+ q.Set("state", verifier)
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+}
+
+// ExchangeToken exchanges the authorization code for an OAuth2 token.
+func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) {
+ code = strings.TrimSpace(code)
+ parts := strings.SplitN(code, "#", 2)
+ pure := parts[0]
+ state := ""
+ if len(parts) > 1 {
+ state = parts[1]
+ }
+
+ reqBody := map[string]string{
+ "code": pure,
+ "state": state,
+ "grant_type": "authorization_code",
+ "client_id": clientId,
+ "redirect_uri": "https://console.anthropic.com/oauth/code/callback",
+ "code_verifier": verifier,
+ }
+
+ resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
+ 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 {
+ return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body))
+ }
+
+ var token oauth.Token
+ if err := json.Unmarshal(body, &token); err != nil {
+ return nil, err
+ }
+ token.SetExpiresAt()
+ return &token, nil
+}
+
+// RefreshToken refreshes the OAuth2 token using the provided refresh token.
+func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
+ reqBody := map[string]string{
+ "grant_type": "refresh_token",
+ "refresh_token": refreshToken,
+ "client_id": clientId,
+ }
+
+ resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
+ 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 {
+ return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body))
+ }
+
+ var token oauth.Token
+ if err := json.Unmarshal(body, &token); err != nil {
+ return nil, err
+ }
+ token.SetExpiresAt()
+ return &token, nil
+}
+
+func request(ctx context.Context, method, url string, body any) (*http.Response, error) {
+ date, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "anthropic")
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ return client.Do(req)
+}

View File

@@ -0,0 +1,244 @@
--- /dev/null
+++ internal/ui/dialog/oauth_claude.go
@@ -0,0 +1,241 @@
+package dialog
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "charm.land/bubbles/v2/help"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/spinner"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/oauth"
+ "github.com/charmbracelet/crush/internal/oauth/claude"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/util"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/pkg/browser"
+)
+
+type oauthClaudeState int
+
+const (
+ oauthClaudeStateInput oauthClaudeState = iota
+ oauthClaudeStateExchanging
+ oauthClaudeStateError
+)
+
+type oauthClaudeComplete struct{ token *oauth.Token }
+type oauthClaudeError struct{ err error }
+
+// OAuthClaudeID is the identifier for the Claude OAuth dialog.
+const OAuthClaudeID = "oauth_claude"
+
+// OAuthClaude is a dialog for Claude Code Max PKCE authentication.
+type OAuthClaude struct {
+ com *common.Common
+ isOnboarding bool
+ provider catwalk.Provider
+ model config.SelectedModel
+ modelType config.SelectedModelType
+
+ width int
+ state oauthClaudeState
+ verifier string
+ verificationURL string
+
+ keyMap struct {
+ Submit key.Binding
+ Open key.Binding
+ Close key.Binding
+ }
+ input textinput.Model
+ spinner spinner.Model
+ help help.Model
+}
+
+var _ Dialog = (*OAuthClaude)(nil)
+
+// NewOAuthClaude creates a new Claude PKCE authentication dialog.
+func NewOAuthClaude(
+ com *common.Common,
+ isOnboarding bool,
+ provider catwalk.Provider,
+ model config.SelectedModel,
+ modelType config.SelectedModelType,
+) (*OAuthClaude, tea.Cmd) {
+ verifier, challenge, err := claude.GetChallenge()
+ if err != nil {
+ m := &OAuthClaude{com: com, isOnboarding: isOnboarding, provider: provider, model: model, modelType: modelType, width: 60, state: oauthClaudeStateError}
+ return m, util.ReportError(fmt.Errorf("failed to generate PKCE challenge: %w", err))
+ }
+ authURL, err := claude.AuthorizeURL(verifier, challenge)
+ if err != nil {
+ m := &OAuthClaude{com: com, isOnboarding: isOnboarding, provider: provider, model: model, modelType: modelType, width: 60, state: oauthClaudeStateError}
+ return m, util.ReportError(fmt.Errorf("failed to build auth URL: %w", err))
+ }
+
+ t := com.Styles
+ m := &OAuthClaude{
+ com: com, isOnboarding: isOnboarding, provider: provider,
+ model: model, modelType: modelType, width: 60,
+ state: oauthClaudeStateInput, verifier: verifier, verificationURL: authURL,
+ }
+
+ innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2
+ m.input = textinput.New()
+ m.input.SetVirtualCursor(false)
+ m.input.Placeholder = "Paste authorization code..."
+ m.input.SetStyles(com.Styles.TextInput)
+ m.input.Focus()
+ m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1))
+ m.input.Prompt = "> "
+
+ m.spinner = spinner.New(
+ spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(t.Dialog.APIKey.Spinner),
+ )
+ m.help = help.New()
+ m.help.Styles = t.DialogHelpStyles()
+
+ m.keyMap.Submit = key.NewBinding(key.WithKeys("enter", "ctrl+y"), key.WithHelp("enter", "submit"))
+ m.keyMap.Open = key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open browser"))
+ m.keyMap.Close = CloseKey
+
+ return m, nil
+}
+
+func (m *OAuthClaude) ID() string { return OAuthClaudeID }
+
+func (m *OAuthClaude) HandleMsg(msg tea.Msg) Action {
+ switch msg := msg.(type) {
+ case oauthClaudeComplete:
+ err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), msg.token)
+ if err != nil {
+ return ActionCmd{util.ReportError(fmt.Errorf("failed to save token: %w", err))}
+ }
+ return ActionSelectModel{Provider: m.provider, Model: m.model, ModelType: m.modelType}
+
+ case oauthClaudeError:
+ m.state = oauthClaudeStateError
+ return ActionCmd{util.ReportError(msg.err)}
+
+ case spinner.TickMsg:
+ if m.state == oauthClaudeStateExchanging {
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ if cmd != nil {
+ return ActionCmd{cmd}
+ }
+ }
+
+ case tea.KeyPressMsg:
+ switch {
+ case m.state == oauthClaudeStateExchanging:
+ // absorb input during exchange
+ case key.Matches(msg, m.keyMap.Close):
+ return ActionClose{}
+ case key.Matches(msg, m.keyMap.Open) && m.state == oauthClaudeStateInput:
+ _ = browser.OpenURL(m.verificationURL)
+ case key.Matches(msg, m.keyMap.Submit) && m.state == oauthClaudeStateInput:
+ code := strings.TrimSpace(m.input.Value())
+ if code == "" {
+ return nil
+ }
+ m.state = oauthClaudeStateExchanging
+ verifier := m.verifier
+ return ActionCmd{tea.Batch(m.spinner.Tick, func() tea.Msg {
+ token, err := claude.ExchangeToken(context.Background(), code, verifier)
+ if err != nil {
+ return oauthClaudeError{err}
+ }
+ return oauthClaudeComplete{token}
+ })}
+ default:
+ if m.state == oauthClaudeStateInput {
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ if cmd != nil {
+ return ActionCmd{cmd}
+ }
+ }
+ }
+
+ case tea.PasteMsg:
+ if m.state == oauthClaudeStateInput {
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ if cmd != nil {
+ return ActionCmd{cmd}
+ }
+ }
+ }
+ return nil
+}
+
+func (m *OAuthClaude) Cursor() *tea.Cursor {
+ return InputCursor(m.com.Styles, m.input.Cursor())
+}
+
+func (m *OAuthClaude) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ t := m.com.Styles
+ dialogStyle := t.Dialog.View.Width(m.width)
+ helpStyle := t.Dialog.HelpView.Width(m.width - dialogStyle.GetHorizontalFrameSize())
+ textStyle := t.Dialog.SecondaryText
+ inputStyle := t.Dialog.InputPrompt
+ linkStyle := t.Dialog.OAuth.Link
+
+ if m.state == oauthClaudeStateExchanging {
+ m.input.Prompt = m.spinner.View()
+ } else {
+ m.input.Prompt = "> "
+ }
+
+ content := strings.Join([]string{
+ m.headerView(),
+ textStyle.Render("Visit the URL and paste the authorization code:"),
+ "",
+ textStyle.Render(linkStyle.Hyperlink(m.verificationURL, "id=oauth-claude").Render(m.verificationURL)),
+ "",
+ inputStyle.Render(m.input.View()),
+ "",
+ helpStyle.Render(m.help.View(m)),
+ }, "\n")
+
+ cur := m.Cursor()
+ if m.isOnboarding {
+ cur = adjustOnboardingInputCursor(t, cur)
+ DrawOnboardingCursor(scr, area, content, cur)
+ } else {
+ view := dialogStyle.Render(content)
+ DrawCenterCursor(scr, area, view, cur)
+ }
+ return cur
+}
+
+func (m *OAuthClaude) headerView() string {
+ t := m.com.Styles
+ titleStyle := t.Dialog.Title
+ textStyle := t.Dialog.PrimaryText
+ dialogStyle := t.Dialog.View.Width(m.width)
+ title := fmt.Sprintf("Authenticate with %s", m.provider.Name)
+ if m.isOnboarding {
+ return textStyle.Render(title)
+ }
+ headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+ return common.DialogTitle(t, titleStyle.Render(title), m.width-headerOffset, t.Dialog.TitleGradFromColor, t.Dialog.TitleGradToColor)
+}
+
+func (m *OAuthClaude) ShortHelp() []key.Binding {
+ if m.state == oauthClaudeStateInput {
+ return []key.Binding{m.keyMap.Open, m.keyMap.Submit, m.keyMap.Close}
+ }
+ return []key.Binding{m.keyMap.Close}
+}
+
+func (m *OAuthClaude) FullHelp() [][]key.Binding {
+ return [][]key.Binding{m.ShortHelp()}
+}

View File

@@ -0,0 +1,11 @@
--- internal/ui/model/ui.go
+++ internal/ui/model/ui.go
@@ -1952,6 +1952,8 @@ func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.Se
switch provider.ID {
case "hyper":
dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
+ case catwalk.InferenceProviderAnthropic:
+ dlg, cmd = dialog.NewOAuthClaude(m.com, isOnboarding, provider, model, modelType)
case catwalk.InferenceProviderCopilot:
dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
default: