246 lines
7.3 KiB
Plaintext
246 lines
7.3 KiB
Plaintext
Index: internal/ui/dialog/oauth_claude.go
|
|
--- internal/ui/dialog/oauth_claude.go.orig
|
|
+++ 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()}
|
|
+}
|