diff --git a/Makefile b/Makefile index f01a9bd..99a99a6 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ extract-schema: - go run cmd/extract + go run ./cmd/extract generate: cd cursor && buf generate diff --git a/README.md b/README.md index 7426d71..9215713 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/client.go b/client.go new file mode 100644 index 0000000..0685347 --- /dev/null +++ b/client.go @@ -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(), + ) +} diff --git a/cmd/example/main.go b/cmd/example/main.go new file mode 100644 index 0000000..46df4fd --- /dev/null +++ b/cmd/example/main.go @@ -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() +} diff --git a/cursor/buf.gen.yaml b/cursor/buf.gen.yaml index 4da6d92..6ab5d57 100644 --- a/cursor/buf.gen.yaml +++ b/cursor/buf.gen.yaml @@ -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 diff --git a/cursor/gen/aiserver/v1/aiserver.pb.go b/cursor/gen/aiserver/v1/aiserver.pb.go index bb00256..ab35747 100644 --- a/cursor/gen/aiserver/v1/aiserver.pb.go +++ b/cursor/gen/aiserver/v1/aiserver.pb.go @@ -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 ( diff --git a/cursor/gen/aiserver/v1/aiserverv1connect/aiserver.connect.go b/cursor/gen/aiserver/v1/aiserverv1connect/aiserver.connect.go index a047912..ee00154 100644 --- a/cursor/gen/aiserver/v1/aiserverv1connect/aiserver.connect.go +++ b/cursor/gen/aiserver/v1/aiserverv1connect/aiserver.connect.go @@ -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" ) diff --git a/go.mod b/go.mod index bbb59cc..7ce9346 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f69e153 --- /dev/null +++ b/go.sum @@ -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=