devel/crush: new port, crush 0.82.0
This commit is contained in:
120
devel/crush/patches/patch-internal_cmd_login_go
Normal file
120
devel/crush/patches/patch-internal_cmd_login_go
Normal 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
|
||||
}
|
||||
|
||||
23
devel/crush/patches/patch-internal_config_config_go
Normal file
23
devel/crush/patches/patch-internal_config_config_go
Normal 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
|
||||
18
devel/crush/patches/patch-internal_config_load_go
Normal file
18
devel/crush/patches/patch-internal_config_load_go
Normal 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()
|
||||
}
|
||||
52
devel/crush/patches/patch-internal_config_store_go
Normal file
52
devel/crush/patches/patch-internal_config_store_go
Normal 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)
|
||||
31
devel/crush/patches/patch-internal_oauth_claude_challenge_go
Normal file
31
devel/crush/patches/patch-internal_oauth_claude_challenge_go
Normal 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
|
||||
+}
|
||||
129
devel/crush/patches/patch-internal_oauth_claude_oauth_go
Normal file
129
devel/crush/patches/patch-internal_oauth_claude_oauth_go
Normal 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)
|
||||
+}
|
||||
244
devel/crush/patches/patch-internal_ui_dialog_oauth_claude_go
Normal file
244
devel/crush/patches/patch-internal_ui_dialog_oauth_claude_go
Normal 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()}
|
||||
+}
|
||||
11
devel/crush/patches/patch-internal_ui_model_ui_go
Normal file
11
devel/crush/patches/patch-internal_ui_model_ui_go
Normal 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:
|
||||
Reference in New Issue
Block a user