login.go

  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}