-
-
Notifications
You must be signed in to change notification settings - Fork 386
feat: add Cursor IDE provider with cursor-agent integration #451
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| // Package cursor provides OAuth authentication for Cursor IDE using cursor-agent login. | ||
| package cursor | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "regexp" | ||
| "runtime" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" | ||
| "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | ||
| log "github.com/sirupsen/logrus" | ||
| ) | ||
|
|
||
| const ( | ||
| authPollInterval = 2 * time.Second | ||
| authPollTimeout = 5 * time.Minute | ||
| urlExtractTimeout = 10 * time.Second | ||
| ) | ||
|
|
||
| // ansiPattern matches ANSI escape codes. | ||
| var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) | ||
|
|
||
| // loginURLPattern matches the Cursor login URL. | ||
| var loginURLPattern = regexp.MustCompile(`https://cursor\.com/loginDeepControl[^\s]*`) | ||
|
|
||
| // CursorOAuth handles the OAuth flow for Cursor authentication. | ||
| type CursorOAuth struct { | ||
| cfg *config.Config | ||
| } | ||
|
|
||
| // NewCursorOAuth creates a new Cursor OAuth handler. | ||
| func NewCursorOAuth(cfg *config.Config) *CursorOAuth { | ||
| return &CursorOAuth{cfg: cfg} | ||
| } | ||
|
|
||
| // cursorAgentBinary returns the cursor-agent binary path. | ||
| func (c *CursorOAuth) cursorAgentBinary() string { | ||
| if c.cfg != nil && c.cfg.CursorAgentPath != "" { | ||
| return c.cfg.CursorAgentPath | ||
| } | ||
| return "cursor-agent" | ||
| } | ||
|
|
||
| // Login initiates the cursor-agent login flow and waits for authentication. | ||
| func (c *CursorOAuth) Login(ctx context.Context) (*CursorTokenData, error) { | ||
| binary := c.cursorAgentBinary() | ||
|
|
||
| // Spawn cursor-agent login | ||
| cmd := exec.CommandContext(ctx, binary, "login") | ||
| stdout, err := cmd.StdoutPipe() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("cursor: stdout pipe: %w", err) | ||
| } | ||
| stderr, err := cmd.StderrPipe() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("cursor: stderr pipe: %w", err) | ||
| } | ||
|
|
||
| if err := cmd.Start(); err != nil { | ||
| return nil, fmt.Errorf("cursor: start login: %w", err) | ||
| } | ||
|
|
||
| // Read stdout in background to extract login URL | ||
| stdoutCh := make(chan string, 1) | ||
| go func() { | ||
| buf := make([]byte, 8192) | ||
| var output strings.Builder | ||
| for { | ||
| n, readErr := stdout.Read(buf) | ||
| if n > 0 { | ||
| output.Write(buf[:n]) | ||
| } | ||
| if readErr != nil { | ||
| break | ||
| } | ||
| } | ||
| stdoutCh <- output.String() | ||
| }() | ||
|
|
||
| // Read stderr in background | ||
| go func() { | ||
| buf := make([]byte, 4096) | ||
| for { | ||
| _, readErr := stderr.Read(buf) | ||
| if readErr != nil { | ||
| break | ||
| } | ||
| } | ||
| }() | ||
|
|
||
| // Try to extract URL with polling | ||
| var loginURL string | ||
| extractStart := time.Now() | ||
| for loginURL == "" && time.Since(extractStart) < urlExtractTimeout { | ||
| select { | ||
| case rawOutput := <-stdoutCh: | ||
| loginURL = extractLoginURL(rawOutput) | ||
| if loginURL == "" { | ||
| // Put it back for later | ||
| go func() { stdoutCh <- rawOutput }() | ||
| } | ||
| case <-time.After(100 * time.Millisecond): | ||
| // Keep waiting | ||
| case <-ctx.Done(): | ||
| _ = cmd.Process.Kill() | ||
| return nil, ctx.Err() | ||
| } | ||
| } | ||
|
|
||
| if loginURL != "" { | ||
| log.Infof("cursor: login URL extracted, opening browser") | ||
| fmt.Printf("Please visit: %s\n", loginURL) | ||
| if browser.IsAvailable() { | ||
| if errOpen := browser.OpenURL(loginURL); errOpen != nil { | ||
| log.Warnf("cursor: failed to open browser: %v", errOpen) | ||
| } | ||
| } | ||
| } else { | ||
| log.Warn("cursor: could not extract login URL from cursor-agent output") | ||
| } | ||
|
|
||
| // Wait for cursor-agent to exit | ||
| go func() { | ||
| _ = cmd.Wait() | ||
| }() | ||
|
|
||
| // Poll for auth file | ||
| log.Info("cursor: waiting for authentication...") | ||
| tokenData, err := c.pollForAuthFile(ctx) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("cursor: auth polling: %w", err) | ||
| } | ||
|
|
||
| return tokenData, nil | ||
| } | ||
|
|
||
| // extractLoginURL strips ANSI codes, removes whitespace, and extracts the login URL. | ||
| func extractLoginURL(output string) string { | ||
| // Strip ANSI codes | ||
| clean := ansiPattern.ReplaceAllString(output, "") | ||
| // Remove all whitespace (URL may be split across lines) | ||
| clean = strings.Join(strings.Fields(clean), "") | ||
| // Extract URL | ||
| match := loginURLPattern.FindString(clean) | ||
| return match | ||
| } | ||
|
|
||
| // pollForAuthFile polls for the cursor auth file and reads token data. | ||
| func (c *CursorOAuth) pollForAuthFile(ctx context.Context) (*CursorTokenData, error) { | ||
| deadline := time.Now().Add(authPollTimeout) | ||
|
|
||
| for time.Now().Before(deadline) { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return nil, ctx.Err() | ||
| default: | ||
| } | ||
|
|
||
| authPath, err := c.FindAuthFile() | ||
| if err == nil && authPath != "" { | ||
| tokenData, readErr := c.ReadAuthFile(authPath) | ||
| if readErr == nil { | ||
| log.Infof("cursor: auth file found at %s", authPath) | ||
| return tokenData, nil | ||
| } | ||
| log.Debugf("cursor: auth file found but unreadable: %v", readErr) | ||
| } | ||
|
|
||
| time.Sleep(authPollInterval) | ||
| } | ||
|
|
||
| return nil, fmt.Errorf("authentication timed out after %v", authPollTimeout) | ||
| } | ||
|
|
||
| // GetPossibleAuthPaths returns all possible cursor auth file paths in priority order. | ||
| func (c *CursorOAuth) GetPossibleAuthPaths() []string { | ||
| home := os.Getenv("CURSOR_ACP_HOME_DIR") | ||
| if home == "" { | ||
| homeDir, err := os.UserHomeDir() | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| home = homeDir | ||
| } | ||
|
|
||
| authFiles := []string{"cli-config.json", "auth.json"} | ||
| var paths []string | ||
|
|
||
| if runtime.GOOS == "darwin" { | ||
| for _, f := range authFiles { | ||
| paths = append(paths, filepath.Join(home, ".cursor", f)) | ||
| } | ||
| for _, f := range authFiles { | ||
| paths = append(paths, filepath.Join(home, ".config", "cursor", f)) | ||
| } | ||
| } else { | ||
| // Linux | ||
| for _, f := range authFiles { | ||
| paths = append(paths, filepath.Join(home, ".config", "cursor", f)) | ||
| } | ||
| xdgConfig := os.Getenv("XDG_CONFIG_HOME") | ||
| if xdgConfig != "" && xdgConfig != filepath.Join(home, ".config") { | ||
| for _, f := range authFiles { | ||
| paths = append(paths, filepath.Join(xdgConfig, "cursor", f)) | ||
| } | ||
| } | ||
| for _, f := range authFiles { | ||
| paths = append(paths, filepath.Join(home, ".cursor", f)) | ||
| } | ||
| } | ||
|
|
||
| return paths | ||
| } | ||
|
|
||
| // FindAuthFile returns the first existing auth file path. | ||
| func (c *CursorOAuth) FindAuthFile() (string, error) { | ||
| paths := c.GetPossibleAuthPaths() | ||
| for _, p := range paths { | ||
| if _, err := os.Stat(p); err == nil { | ||
| return p, nil | ||
| } | ||
| } | ||
| return "", fmt.Errorf("no cursor auth file found") | ||
| } | ||
|
|
||
| // ReadAuthFile reads and parses a cursor auth file. | ||
| func (c *CursorOAuth) ReadAuthFile(path string) (*CursorTokenData, error) { | ||
| data, err := os.ReadFile(path) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("read auth file: %w", err) | ||
| } | ||
|
|
||
| var tokenData CursorTokenData | ||
| if err := json.Unmarshal(data, &tokenData); err != nil { | ||
| return nil, fmt.Errorf("parse auth file: %w", err) | ||
| } | ||
|
|
||
| if tokenData.AccessToken == "" { | ||
| return nil, fmt.Errorf("no access token in auth file") | ||
| } | ||
|
|
||
| tokenData.AuthFile = path | ||
| return &tokenData, nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // Package cursor provides HTML templates for Cursor OAuth status pages. | ||
| package cursor | ||
|
|
||
| // SuccessHTML is displayed after successful Cursor authentication. | ||
| const SuccessHTML = `<!DOCTYPE html> | ||
| <html> | ||
| <head><title>Cursor Auth</title></head> | ||
| <body style="font-family: sans-serif; text-align: center; padding: 50px;"> | ||
| <h1>Cursor Authentication Successful</h1> | ||
| <p>You can close this window and return to the terminal.</p> | ||
| </body> | ||
| </html>` | ||
|
|
||
| // FailureHTML is displayed when Cursor authentication fails. | ||
| const FailureHTML = `<!DOCTYPE html> | ||
| <html> | ||
| <head><title>Cursor Auth</title></head> | ||
| <body style="font-family: sans-serif; text-align: center; padding: 50px;"> | ||
| <h1>Cursor Authentication Failed</h1> | ||
| <p>%s</p> | ||
| <p>Please try again.</p> | ||
| </body> | ||
| </html>` | ||
|
Comment on lines
+1
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file defines HTML templates for a web-based OAuth flow. However, the Cursor authentication implemented in this pull request uses the command-line |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation for reading
stdoutfrom thecursor-agentprocess and extracting the login URL has a critical bug that will lead to a deadlock. The goroutine on lines 72-85 reads from thestdoutpipe in a loop until it encounters an error (likeio.EOF), which typically happens only when the process exits. However, thecursor-agent loginprocess prints the URL and then waits for the user to authenticate in the browser before exiting. This creates a deadlock: the Go code waits for the process to exit to get the URL, and the process waits for the user to use the URL to exit.The URL extraction loop on lines 98-115 is also problematic, as it uses
go func() { stdoutCh <- rawOutput }()to put data back onto the channel, which is an inefficient busy-wait pattern.I suggest refactoring this section to process the
stdoutstream as it arrives and extract the URL without waiting for the process to terminate.