1package cmd
2
3import (
4 "cmp"
5 "context"
6 "fmt"
7 "os"
8 "os/signal"
9 "strings"
10
11 "charm.land/lipgloss/v2"
12 "github.com/atotto/clipboard"
13 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/oauth"
16 "github.com/charmbracelet/crush/internal/oauth/claude"
17 "github.com/charmbracelet/crush/internal/oauth/copilot"
18 "github.com/charmbracelet/crush/internal/oauth/hyper"
19 "github.com/pkg/browser"
20 "github.com/spf13/cobra"
21)
22
23var loginCmd = &cobra.Command{
24 Aliases: []string{"auth"},
25 Use: "login [platform]",
26 Short: "Login Crush to a platform",
27 Long: `Login Crush to a specified platform.
28The platform should be provided as an argument.
29Available platforms are: hyper, claude, copilot.`,
30 Example: `
31# Authenticate with Charm Hyper
32crush login
33
34# Authenticate with Claude Code Max
35crush login claude
36
37# Authenticate with GitHub Copilot
38crush login copilot
39 `,
40 ValidArgs: []cobra.Completion{
41 "hyper",
42 "claude",
43 "anthropic",
44 "copilot",
45 "github",
46 "github-copilot",
47 },
48 Args: cobra.MaximumNArgs(1),
49 RunE: func(cmd *cobra.Command, args []string) error {
50 app, err := setupAppWithProgressBar(cmd)
51 if err != nil {
52 return err
53 }
54 defer app.Shutdown()
55
56 provider := "hyper"
57 if len(args) > 0 {
58 provider = args[0]
59 }
60 switch provider {
61 case "hyper":
62 return loginHyper()
63 case "anthropic", "claude":
64 return loginClaude()
65 case "copilot", "github", "github-copilot":
66 return loginCopilot()
67 default:
68 return fmt.Errorf("unknown platform: %s", args[0])
69 }
70 },
71}
72
73func loginHyper() error {
74 cfg := config.Get()
75 if !hyperp.Enabled() {
76 return fmt.Errorf("hyper not enabled")
77 }
78 ctx := getLoginContext()
79
80 resp, err := hyper.InitiateDeviceAuth(ctx)
81 if err != nil {
82 return err
83 }
84
85 if clipboard.WriteAll(resp.UserCode) == nil {
86 fmt.Println("The following code should be on clipboard already:")
87 } else {
88 fmt.Println("Copy the following code:")
89 }
90
91 fmt.Println()
92 fmt.Println(lipgloss.NewStyle().Bold(true).Render(resp.UserCode))
93 fmt.Println()
94 fmt.Println("Press enter to open this URL, and then paste it there:")
95 fmt.Println()
96 fmt.Println(lipgloss.NewStyle().Hyperlink(resp.VerificationURL, "id=hyper").Render(resp.VerificationURL))
97 fmt.Println()
98 waitEnter()
99 if err := browser.OpenURL(resp.VerificationURL); err != nil {
100 fmt.Println("Could not open the URL. You'll need to manually open the URL in your browser.")
101 }
102
103 fmt.Println("Exchanging authorization code...")
104 refreshToken, err := hyper.PollForToken(ctx, resp.DeviceCode, resp.ExpiresIn)
105 if err != nil {
106 return err
107 }
108
109 fmt.Println("Exchanging refresh token for access token...")
110 token, err := hyper.ExchangeToken(ctx, refreshToken)
111 if err != nil {
112 return err
113 }
114
115 fmt.Println("Verifying access token...")
116 introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
117 if err != nil {
118 return fmt.Errorf("token introspection failed: %w", err)
119 }
120 if !introspect.Active {
121 return fmt.Errorf("access token is not active")
122 }
123
124 if err := cmp.Or(
125 cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
126 cfg.SetConfigField("providers.hyper.oauth", token),
127 ); err != nil {
128 return err
129 }
130
131 fmt.Println()
132 fmt.Println("You're now authenticated with Hyper!")
133 return nil
134}
135
136func loginClaude() error {
137 ctx := getLoginContext()
138
139 cfg := config.Get()
140 if cfg.HasConfigField("providers.anthropic.oauth") {
141 fmt.Println("You are already logged in to Claude.")
142 return nil
143 }
144
145 verifier, challenge, err := claude.GetChallenge()
146 if err != nil {
147 return err
148 }
149 url, err := claude.AuthorizeURL(verifier, challenge)
150 if err != nil {
151 return err
152 }
153 fmt.Println("Open the following URL and follow the instructions to authenticate with Claude Code Max:")
154 fmt.Println()
155 fmt.Println(lipgloss.NewStyle().Hyperlink(url, "id=claude").Render(url))
156 fmt.Println()
157 fmt.Println("Press enter to continue...")
158 if _, err := fmt.Scanln(); err != nil {
159 return err
160 }
161
162 fmt.Println("Now paste and code from Anthropic and press enter...")
163 fmt.Println()
164 fmt.Print("> ")
165 var code string
166 for code == "" {
167 _, _ = fmt.Scanln(&code)
168 code = strings.TrimSpace(code)
169 }
170
171 fmt.Println()
172 fmt.Println("Exchanging authorization code...")
173 token, err := claude.ExchangeToken(ctx, code, verifier)
174 if err != nil {
175 return err
176 }
177
178 if err := cmp.Or(
179 cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
180 cfg.SetConfigField("providers.anthropic.oauth", token),
181 ); err != nil {
182 return err
183 }
184
185 fmt.Println()
186 fmt.Println("You're now authenticated with Claude Code Max!")
187 return nil
188}
189
190func loginCopilot() error {
191 ctx := getLoginContext()
192
193 cfg := config.Get()
194 if cfg.HasConfigField("providers.copilot.oauth") {
195 fmt.Println("You are already logged in to GitHub Copilot.")
196 return nil
197 }
198
199 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
200 var token *oauth.Token
201
202 switch {
203 case hasDiskToken:
204 fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
205
206 t, err := copilot.RefreshToken(ctx, diskToken)
207 if err != nil {
208 return fmt.Errorf("unable to refresh token from disk: %w", err)
209 }
210 token = t
211 default:
212 fmt.Println("Requesting device code from GitHub...")
213 dc, err := copilot.RequestDeviceCode(ctx)
214 if err != nil {
215 return err
216 }
217
218 fmt.Println()
219 fmt.Println("Open the following URL and follow the instructions to authenticate with GitHub Copilot:")
220 fmt.Println()
221 fmt.Println(lipgloss.NewStyle().Hyperlink(dc.VerificationURI, "id=copilot").Render(dc.VerificationURI))
222 fmt.Println()
223 fmt.Println("Code:", lipgloss.NewStyle().Bold(true).Render(dc.UserCode))
224 fmt.Println()
225 fmt.Println("Waiting for authorization...")
226
227 t, err := copilot.PollForToken(ctx, dc)
228 if err == copilot.ErrNotAvailable {
229 fmt.Println()
230 fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
231 fmt.Println()
232 fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL))
233 fmt.Println()
234 fmt.Println("You may be able to request free access if elegible. For more information, see:")
235 fmt.Println()
236 fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL))
237 }
238 if err != nil {
239 return err
240 }
241 token = t
242 }
243
244 if err := cmp.Or(
245 cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
246 cfg.SetConfigField("providers.copilot.oauth", token),
247 ); err != nil {
248 return err
249 }
250
251 fmt.Println()
252 fmt.Println("You're now authenticated with GitHub Copilot!")
253 return nil
254}
255
256func getLoginContext() context.Context {
257 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
258 go func() {
259 <-ctx.Done()
260 cancel()
261 os.Exit(1)
262 }()
263 return ctx
264}
265
266func waitEnter() {
267 _, _ = fmt.Scanln()
268}