1package claude
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7
8 "charm.land/bubbles/v2/spinner"
9 "charm.land/bubbles/v2/textinput"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/charmbracelet/crush/internal/oauth"
13 "github.com/charmbracelet/crush/internal/oauth/claude"
14 "github.com/charmbracelet/crush/internal/tui/styles"
15 "github.com/charmbracelet/crush/internal/tui/util"
16 "github.com/pkg/browser"
17 "github.com/zeebo/xxh3"
18)
19
20type OAuthState int
21
22const (
23 OAuthStateURL OAuthState = iota
24 OAuthStateCode
25)
26
27type OAuthValidationState int
28
29const (
30 OAuthValidationStateNone OAuthValidationState = iota
31 OAuthValidationStateVerifying
32 OAuthValidationStateValid
33 OAuthValidationStateError
34)
35
36type ValidationCompletedMsg struct {
37 State OAuthValidationState
38 Token *oauth.Token
39}
40
41type AuthenticationCompleteMsg struct{}
42
43type OAuth2 struct {
44 State OAuthState
45 ValidationState OAuthValidationState
46 width int
47 isOnboarding bool
48
49 // URL page
50 err error
51 verifier string
52 challenge string
53 URL string
54 urlId string
55 token *oauth.Token
56
57 // Code input page
58 CodeInput textinput.Model
59 spinner spinner.Model
60}
61
62func NewOAuth2() *OAuth2 {
63 return &OAuth2{
64 State: OAuthStateURL,
65 }
66}
67
68func (o *OAuth2) Init() tea.Cmd {
69 t := styles.CurrentTheme()
70
71 verifier, challenge, err := claude.GetChallenge()
72 if err != nil {
73 o.err = err
74 return nil
75 }
76
77 url, err := claude.AuthorizeURL(verifier, challenge)
78 if err != nil {
79 o.err = err
80 return nil
81 }
82
83 o.verifier = verifier
84 o.challenge = challenge
85 o.URL = url
86
87 h := xxh3.New()
88 _, _ = h.WriteString(o.URL)
89 o.urlId = fmt.Sprintf("id=%x", h.Sum(nil))
90
91 o.CodeInput = textinput.New()
92 o.CodeInput.Placeholder = "Paste or type"
93 o.CodeInput.SetVirtualCursor(false)
94 o.CodeInput.Prompt = "> "
95 o.CodeInput.SetStyles(t.S().TextInput)
96 o.CodeInput.SetWidth(50)
97
98 o.spinner = spinner.New(
99 spinner.WithSpinner(spinner.Dot),
100 spinner.WithStyle(t.S().Base.Foreground(t.Green)),
101 )
102
103 return nil
104}
105
106func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) {
107 var cmds []tea.Cmd
108
109 switch msg := msg.(type) {
110 case ValidationCompletedMsg:
111 o.ValidationState = msg.State
112 o.token = msg.Token
113 switch o.ValidationState {
114 case OAuthValidationStateError:
115 o.CodeInput.Focus()
116 }
117 o.updatePrompt()
118 }
119
120 if o.ValidationState == OAuthValidationStateVerifying {
121 var cmd tea.Cmd
122 o.spinner, cmd = o.spinner.Update(msg)
123 cmds = append(cmds, cmd)
124 o.updatePrompt()
125 }
126 {
127 var cmd tea.Cmd
128 o.CodeInput, cmd = o.CodeInput.Update(msg)
129 cmds = append(cmds, cmd)
130 }
131
132 return o, tea.Batch(cmds...)
133}
134
135func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) {
136 var cmds []tea.Cmd
137
138 switch {
139 case o.State == OAuthStateURL:
140 _ = browser.OpenURL(o.URL)
141 o.State = OAuthStateCode
142 cmds = append(cmds, o.CodeInput.Focus())
143 case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError:
144 o.CodeInput.Blur()
145 o.ValidationState = OAuthValidationStateVerifying
146 cmds = append(cmds, o.spinner.Tick, o.validateCode)
147 case o.ValidationState == OAuthValidationStateValid:
148 cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} })
149 }
150
151 o.updatePrompt()
152 return o, tea.Batch(cmds...)
153}
154
155func (o *OAuth2) View() string {
156 t := styles.CurrentTheme()
157
158 whiteStyle := lipgloss.NewStyle().Foreground(t.White)
159 primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
160 successStyle := lipgloss.NewStyle().Foreground(t.Success)
161 errorStyle := lipgloss.NewStyle().Foreground(t.Error)
162
163 titleStyle := whiteStyle
164 if o.isOnboarding {
165 titleStyle = primaryStyle
166 }
167
168 switch {
169 case o.err != nil:
170 return lipgloss.NewStyle().
171 Margin(0, 1).
172 Foreground(t.Error).
173 Render(o.err.Error())
174 case o.State == OAuthStateURL:
175 heading := lipgloss.
176 NewStyle().
177 Margin(0, 1).
178 Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":"))
179
180 return lipgloss.JoinVertical(
181 lipgloss.Left,
182 heading,
183 "",
184 lipgloss.NewStyle().
185 Margin(0, 1).
186 Foreground(t.FgMuted).
187 Hyperlink(o.URL, o.urlId).
188 Render(o.displayUrl()),
189 )
190 case o.State == OAuthStateCode:
191 var heading string
192
193 switch o.ValidationState {
194 case OAuthValidationStateNone:
195 st := lipgloss.NewStyle().Margin(0, 1)
196 heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received."))
197 case OAuthValidationStateVerifying:
198 heading = titleStyle.Margin(0, 1).Render("Verifying...")
199 case OAuthValidationStateValid:
200 heading = successStyle.Margin(0, 1).Render("Validated.")
201 case OAuthValidationStateError:
202 heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?")
203 }
204
205 return lipgloss.JoinVertical(
206 lipgloss.Left,
207 heading,
208 "",
209 " "+o.CodeInput.View(),
210 )
211 default:
212 panic("claude oauth2: invalid state")
213 }
214}
215
216func (o *OAuth2) SetDefaults() {
217 o.State = OAuthStateURL
218 o.ValidationState = OAuthValidationStateNone
219 o.CodeInput.SetValue("")
220 o.err = nil
221}
222
223func (o *OAuth2) SetWidth(w int) {
224 o.width = w
225 o.CodeInput.SetWidth(w - 4)
226}
227
228func (o *OAuth2) SetError(err error) {
229 o.err = err
230}
231
232func (o *OAuth2) validateCode() tea.Msg {
233 token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier)
234 if err != nil || token == nil {
235 return ValidationCompletedMsg{State: OAuthValidationStateError}
236 }
237 return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token}
238}
239
240func (o *OAuth2) updatePrompt() {
241 switch o.ValidationState {
242 case OAuthValidationStateNone:
243 o.CodeInput.Prompt = "> "
244 case OAuthValidationStateVerifying:
245 o.CodeInput.Prompt = o.spinner.View() + " "
246 case OAuthValidationStateValid:
247 o.CodeInput.Prompt = styles.CheckIcon + " "
248 case OAuthValidationStateError:
249 o.CodeInput.Prompt = styles.ErrorIcon + " "
250 }
251}
252
253// Remove query params for display
254// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..."
255func (o *OAuth2) displayUrl() string {
256 parsed, err := url.Parse(o.URL)
257 if err != nil {
258 return o.URL
259 }
260
261 if parsed.RawQuery != "" {
262 parsed.RawQuery = ""
263 return parsed.String() + "..."
264 }
265
266 return o.URL
267}