Regenerate, add client lib helper code, update README

This commit is contained in:
Everest Munro
2024-12-01 20:13:15 -08:00
parent 07da25fcd3
commit 7a95ddc619
9 changed files with 288 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
extract-schema:
go run cmd/extract
go run ./cmd/extract
generate:
cd cursor && buf generate

View File

@@ -6,7 +6,48 @@ Works by reverse-engineering the minified, obfuscated JS in Cursor's VSCode fork
## Usage
TODO
### Generating client libraries
Use the .proto files in `./cursor/aiserver/v1` to generate an RPC client library for your language.
### Go library
For detailed usage, check out [the basic example](cmd/example/main.go).
```go
// Get default credentials for Cursor:
// NOTE: you will need to open Cursor and log in at least once. You may need to re-login to
// refresh these credentials, or use the RefreshToken to get a new AccessToken.
creds, err := cursor.GetDefaultCredentials()
if err != nil {
log.Fatal(err)
}
// Set up a service:
aiService := cursor.NewAiServiceClient()
// Get completions!
model := "gpt-4"
resp, err := aiService.StreamChat(context.TODO(), cursor.NewRequest(creds, &aiserverv1.GetChatRequest{
ModelDetails: &aiserverv1.ModelDetails{
ModelName: &model,
},
Conversation: []*aiserverv1.ConversationMessage{
{
Text: "Hello, who are you?",
Type: aiserverv1.ConversationMessage_MESSAGE_TYPE_HUMAN,
},
},
}))
if err != nil {
log.Fatal(err)
}
for resp.Receive() {
next := resp.Msg()
fmt.Printf(next.Text)
}
```
## Updating the schema

163
client.go Normal file
View File

@@ -0,0 +1,163 @@
package cursor
import (
_ "embed"
"encoding/base64"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"connectrpc.com/connect"
"github.com/everestmz/cursor-rpc/cursor/gen/aiserver/v1/aiserverv1connect"
)
const CursorStateDbPath = "User/globalStorage/state.vscdb"
//go:embed cursor_version.txt
var cursorVersion string
func GetCursorVersion() string {
return strings.TrimSpace(cursorVersion)
}
type CursorCredentials struct {
AccessToken string `json:"authToken"`
RefreshToken string `json:"refreshToken"`
}
func generateChecksum(machineID string) string {
// Get current timestamp and convert to uint64
timestamp := uint64(time.Now().UnixNano() / 1e6)
// Convert timestamp to 6-byte array
timestampBytes := []byte{
byte(timestamp >> 40),
byte(timestamp >> 32),
byte(timestamp >> 24),
byte(timestamp >> 16),
byte(timestamp >> 8),
byte(timestamp),
}
// Apply rolling XOR encryption (function S in the original code)
encryptedBytes := encryptBytes(timestampBytes)
// Convert to base64
base64Encoded := base64.StdEncoding.EncodeToString(encryptedBytes)
// Concatenate with machineID
return fmt.Sprintf("%s%s", base64Encoded, machineID)
}
func encryptBytes(input []byte) []byte {
w := byte(165)
for i := 0; i < len(input); i++ {
input[i] = (input[i] ^ w) + byte(i%256)
w = input[i]
}
return input
}
func NewRequest[T any](credentials *CursorCredentials, message *T) *connect.Request[T] {
req := connect.NewRequest(message)
req.Header().Set("authorization", "bearer "+credentials.AccessToken)
req.Header().Set("x-cursor-client-version", GetCursorVersion())
// It doesn't look like the checksum matters. Just that we need one?
// Either way, this is the algorithm used. I just don't know what the arg is.
req.Header().Set("x-cursor-checksum", generateChecksum("hi"))
return req
}
func GetBaseURL() string {
// TODO: round robin other base URLs/do fallbacks
return "https://api2.cursor.sh"
}
func GetRepoClientURL() string {
// TODO: work out why this one is different
return "https://repo42.cursor.sh"
}
func GetCursorDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(homeDir, "Library/Application Support/Cursor"), nil
default:
return "", fmt.Errorf("Unsure what the cursor directory is for GOOS %s - please fix if you know!", runtime.GOOS)
}
}
func GetStateDb() (string, error) {
cursorDir, err := GetCursorDir()
if err != nil {
return "", err
}
return filepath.Join(cursorDir, CursorStateDbPath), nil
}
func GetDefaultCredentials() (*CursorCredentials, error) {
stateDb, err := GetStateDb()
if err != nil {
return nil, err
}
// XXX: it's debatable whether this is good, but the alternatives are either:
// - mattn/sqlite3, which uses CGO. I don't want to force CGO on someone, since this is a lib
// - ncruces/go-sqlite3, which doesn't use CGO, but brings a whole wasm runtime
// - cznic/sqlite, which uses a ton of unsafe pointers
//
// At the end of the day, we just need to run this once on app startup, and most local machines
// have `sqlite3` installed. /shrug
//
// If the user wants to do this another way they can just provide creds via the CursorCredentials type
var getKey = func(key string) (string, error) {
cmd := exec.Command("sqlite3", stateDb, fmt.Sprintf(`SELECT value FROM ItemTable WHERE key = '%s';`, key))
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("error getting %s (cmd %s): %w", key, cmd.String(), err)
}
return strings.TrimSpace(string(out)), nil
}
accessToken, err := getKey("cursorAuth/accessToken")
if err != nil {
return nil, err
}
refreshToken, err := getKey("cursorAuth/refreshToken")
if err != nil {
return nil, err
}
return &CursorCredentials{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
}
func NewRepositoryServiceClient() aiserverv1connect.RepositoryServiceClient {
return aiserverv1connect.NewRepositoryServiceClient(
http.DefaultClient,
GetRepoClientURL(),
)
}
func NewAiServiceClient() aiserverv1connect.AiServiceClient {
return aiserverv1connect.NewAiServiceClient(
http.DefaultClient,
GetBaseURL(),
)
}

60
cmd/example/main.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"context"
"fmt"
"log"
"github.com/everestmz/cursor-rpc"
aiserverv1 "github.com/everestmz/cursor-rpc/cursor/gen/aiserver/v1"
)
func main() {
// Get default credentials from cursor
creds, err := cursor.GetDefaultCredentials()
if err != nil {
log.Fatal(err)
}
// Set up the service
aiService := cursor.NewAiServiceClient()
// Use cursor.NewRequest to inject credentials & create the request object before sending
models, err := aiService.AvailableModels(context.TODO(), cursor.NewRequest(creds, &aiserverv1.AvailableModelsRequest{
IsNightly: true,
IncludeLongContextModels: true,
}))
if err != nil {
log.Fatal(err)
}
fmt.Println("Available models:")
for _, model := range models.Msg.ModelNames {
fmt.Println(" -", model)
}
model := models.Msg.ModelNames[len(models.Msg.ModelNames)-1]
fmt.Println("Selected model", model)
resp, err := aiService.StreamChat(context.TODO(), cursor.NewRequest(creds, &aiserverv1.GetChatRequest{
ModelDetails: &aiserverv1.ModelDetails{
ModelName: &model,
},
Conversation: []*aiserverv1.ConversationMessage{
{
Text: "Hello, who are you?",
Type: aiserverv1.ConversationMessage_MESSAGE_TYPE_HUMAN,
},
},
}))
if err != nil {
log.Fatal(err)
}
for resp.Receive() {
next := resp.Msg()
fmt.Printf(next.Text)
}
fmt.Println()
}

View File

@@ -3,7 +3,7 @@ managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/everestmz/everestmz.github.io/cursor-reversing/client/cursor/gen
value: github.com/everestmz/cursor-rpc/cursor/gen
plugins:
- local: protoc-gen-go
out: gen

View File

@@ -69076,20 +69076,18 @@ var file_aiserver_v1_aiserver_proto_rawDesc = []byte{
0x76, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x4e, 0x75,
0x6d, 0x62, 0x65, 0x72, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42,
0xd1, 0x01, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
0xb0, 0x01, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x42, 0x0d, 0x41, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x50, 0x72, 0x6f,
0x74, 0x6f, 0x50, 0x01, 0x5a, 0x62, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
0x2f, 0x65, 0x76, 0x65, 0x72, 0x65, 0x73, 0x74, 0x6d, 0x7a, 0x2f, 0x65, 0x76, 0x65, 0x72, 0x65,
0x73, 0x74, 0x6d, 0x7a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x69, 0x6f, 0x2f, 0x63,
0x75, 0x72, 0x73, 0x6f, 0x72, 0x2d, 0x72, 0x65, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6e, 0x67, 0x2f,
0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x2f, 0x67, 0x65,
0x6e, 0x2f, 0x61, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x69,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02,
0x0b, 0x41, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0b, 0x41,
0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x17, 0x41, 0x69, 0x73,
0x65, 0x72, 0x76, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0c, 0x41, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a,
0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x74, 0x6f, 0x50, 0x01, 0x5a, 0x41, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
0x2f, 0x65, 0x76, 0x65, 0x72, 0x65, 0x73, 0x74, 0x6d, 0x7a, 0x2f, 0x63, 0x75, 0x72, 0x73, 0x6f,
0x72, 0x2d, 0x72, 0x70, 0x63, 0x2f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x2f, 0x67, 0x65, 0x6e,
0x2f, 0x61, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x69, 0x73,
0x65, 0x72, 0x76, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x0b,
0x41, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0b, 0x41, 0x69,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x17, 0x41, 0x69, 0x73, 0x65,
0x72, 0x76, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0xea, 0x02, 0x0c, 0x41, 0x69, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x3a,
0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@@ -8,7 +8,7 @@ import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
v1 "github.com/everestmz/everestmz.github.io/cursor-reversing/client/cursor/gen/aiserver/v1"
v1 "github.com/everestmz/cursor-rpc/cursor/gen/aiserver/v1"
http "net/http"
strings "strings"
)

5
go.mod
View File

@@ -1,3 +1,8 @@
module github.com/everestmz/cursor-rpc
go 1.21.0
require (
connectrpc.com/connect v1.17.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
connectrpc.com/connect v1.17.0 h1:W0ZqMhtVzn9Zhn2yATuUokDLO5N+gIuBWMOnsQrfmZk=
connectrpc.com/connect v1.17.0/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=