feat(cursor): Integrate Cursor IDE support with OAuth import token flow

- Add CursorExecutor for handling requests to the Cursor API using protobuf over HTTP/2.
- Implement CursorAuthModal for user token import from local SQLite database.
- Update provider models and constants to include Cursor as a supported provider.
- Enhance API service with token validation and user info extraction from Cursor tokens.
- Introduce utility functions for checksum generation and protobuf encoding/decoding for Cursor API interactions.
This commit is contained in:
triadmoko
2026-02-04 12:07:29 +07:00
parent 7881db81ec
commit 137f315bec
17 changed files with 2856 additions and 2 deletions

1098
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -149,6 +149,18 @@ export const PROVIDERS = {
// Kiro OAuth endpoints
tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev"
},
cursor: {
baseUrl: "https://api2.cursor.sh",
chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
format: "cursor",
headers: {
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1"
},
clientVersion: "1.1.3"
}
};

View File

@@ -73,6 +73,18 @@ export const PROVIDER_MODELS = {
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
],
cu: [ // Cursor IDE
{ id: "default", name: "Default (Server Picks)" },
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
{ id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" },
{ id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" },
{ id: "claude-4-sonnet", name: "Claude 4 Sonnet" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-5.1-codex", name: "GPT 5.1 Codex" },
{ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "cursor-small", name: "Cursor Small" },
],
// API Key Providers (alias = id)
openai: [
@@ -149,6 +161,7 @@ export const PROVIDER_ID_TO_ALIAS = {
antigravity: "ag",
github: "gh",
kiro: "kr",
cursor: "cu",
openai: "openai",
anthropic: "anthropic",
gemini: "gemini",

View File

@@ -0,0 +1,516 @@
/**
* CursorExecutor - Executor for Cursor AI IDE
* Uses ConnectRPC/protobuf protocol with HTTP/2 for streaming chat
*/
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/constants.js";
import {
generateCursorBody,
parseConnectRPCFrame,
extractTextFromResponse
} from "../utils/cursorProtobuf.js";
import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
import http2 from "http2";
export class CursorExecutor extends BaseExecutor {
constructor() {
super("cursor", PROVIDERS.cursor);
}
/**
* Build URL for Cursor API
*/
buildUrl() {
return `${this.config.baseUrl}${this.config.chatPath}`;
}
/**
* Generate Cursor checksum (jyh cipher) - timestamp integer version
* This is the format that works with Cursor API
*/
generateChecksum(machineId) {
// Use timestamp / 1e6 format (same as Python demo that works)
const timestamp = Math.floor(Date.now() / 1000000);
// Create 6-byte big-endian array
const byteArray = new Uint8Array([
(timestamp >> 40) & 0xFF,
(timestamp >> 32) & 0xFF,
(timestamp >> 24) & 0xFF,
(timestamp >> 16) & 0xFF,
(timestamp >> 8) & 0xFF,
timestamp & 0xFF
]);
// Jyh cipher obfuscation
let t = 165;
for (let i = 0; i < byteArray.length; i++) {
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF;
t = byteArray[i];
}
// URL-safe base64 encode (without padding)
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let encoded = "";
for (let i = 0; i < byteArray.length; i += 3) {
const a = byteArray[i];
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
encoded += alphabet[a >> 2];
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
if (i + 1 < byteArray.length) {
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
}
if (i + 2 < byteArray.length) {
encoded += alphabet[c & 63];
}
}
return `${encoded}${machineId}`;
}
/**
* Generate client key from token
*/
generateClientKey(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Generate session ID
*/
generateSessionId(token) {
return uuidv5(token, uuidv5.DNS);
}
/**
* Build headers with Cursor checksum authentication
*/
buildHeaders(credentials) {
const accessToken = credentials.accessToken;
const machineId = credentials.providerSpecificData?.machineId;
const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
if (!machineId) {
throw new Error("Machine ID is required for Cursor API");
}
const cleanToken = accessToken.includes("::")
? accessToken.split("::")[1]
: accessToken;
return {
"authorization": `Bearer ${cleanToken}`,
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"content-type": "application/connect+proto",
"user-agent": "connect-es/1.6.1",
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
"x-client-key": this.generateClientKey(cleanToken),
"x-cursor-checksum": this.generateChecksum(machineId),
"x-cursor-client-version": "2.3.41",
"x-cursor-client-type": "ide",
"x-cursor-client-os": process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux",
"x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64",
"x-cursor-client-device-type": "desktop",
"x-cursor-config-version": crypto.randomUUID(),
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
"x-ghost-mode": ghostMode ? "true" : "false",
"x-request-id": crypto.randomUUID(),
"x-session-id": this.generateSessionId(cleanToken),
};
}
/**
* Convert OpenAI-format messages to Cursor format
*/
convertMessages(body) {
const messages = body.messages || [];
const result = [];
for (const msg of messages) {
if (msg.role === "system") {
result.push({
role: "user",
content: `[System Instructions]\n${msg.content}`
});
continue;
}
if (msg.role === "user" || msg.role === "assistant") {
let content = "";
if (typeof msg.content === "string") {
content = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text") {
content += part.text;
}
}
}
if (content) {
result.push({ role: msg.role, content });
}
}
}
return result;
}
/**
* Make HTTP/2 request to Cursor API
*/
makeHttp2Request(url, headers, body, signal) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
const chunks = [];
let responseHeaders = {};
client.on("error", (err) => {
reject(err);
});
const req = client.request({
":method": "POST",
":path": urlObj.pathname,
":authority": urlObj.host,
":scheme": "https",
...headers
});
req.on("response", (hdrs) => {
responseHeaders = hdrs;
});
req.on("data", (chunk) => {
chunks.push(chunk);
});
req.on("end", () => {
client.close();
const data = Buffer.concat(chunks);
resolve({
status: responseHeaders[":status"],
headers: responseHeaders,
body: data
});
});
req.on("error", (err) => {
client.close();
reject(err);
});
if (signal) {
signal.addEventListener("abort", () => {
req.close();
client.close();
reject(new Error("Request aborted"));
});
}
req.write(body);
req.end();
});
}
/**
* Custom execute for Cursor - handles protobuf binary protocol with HTTP/2
*/
async execute({ model, body, stream, credentials, signal, log }) {
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
// Convert messages and build protobuf body
const messages = this.convertMessages(body);
const cursorBody = generateCursorBody(messages, model);
log?.debug?.("CURSOR", `Sending ${messages.length} messages to ${model}, stream=${stream}`);
try {
// Use HTTP/2 for Cursor API (required)
const response = await this.makeHttp2Request(url, headers, cursorBody, signal);
if (response.status !== 200) {
// Create error response
const errorResponse = new Response(JSON.stringify({
error: {
message: `[${response.status}]: ${response.body.toString() || "Unknown error"}`,
type: "invalid_request_error",
code: ""
}
}), {
status: response.status,
headers: { "Content-Type": "application/json" }
});
return { response: errorResponse, url, headers, transformedBody: body };
}
// Transform based on stream parameter
const transformedResponse = stream !== false
? this.transformProtobufToSSE(response.body, model)
: this.transformProtobufToJSON(response.body, model);
return { response: transformedResponse, url, headers, transformedBody: body };
} catch (error) {
log?.error?.("CURSOR", `Request failed: ${error.message}`);
const errorResponse = new Response(JSON.stringify({
error: {
message: error.message,
type: "connection_error",
code: ""
}
}), {
status: 500,
headers: { "Content-Type": "application/json" }
});
return { response: errorResponse, url, headers, transformedBody: body };
}
}
/**
* Transform ConnectRPC protobuf buffer to JSON Response (non-streaming)
*/
transformProtobufToJSON(buffer, model) {
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
// Parse all frames and collect content
let offset = 0;
let totalContent = "";
while (offset < buffer.length) {
if (offset + 5 > buffer.length) break;
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
if (offset + 5 + length > buffer.length) break;
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
// Decompress if gzip (flags 0x01 or 0x03)
if (flags === 0x01 || flags === 0x03) {
try {
const zlib = require("zlib");
payload = zlib.gunzipSync(payload);
} catch {
continue;
}
}
// Check if payload is JSON error (ConnectRPC error format)
try {
const text = payload.toString("utf-8");
if (text.startsWith("{") && text.includes('"error"')) {
const jsonError = JSON.parse(text);
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|| jsonError?.error?.message
|| "API Error";
return new Response(JSON.stringify({
error: {
message: errorMsg,
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
}
}), {
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
headers: { "Content-Type": "application/json" }
});
}
} catch {}
// Extract text or error from protobuf
const result = extractTextFromResponse(new Uint8Array(payload));
if (result.error) {
// Return error response
return new Response(JSON.stringify({
error: {
message: result.error,
type: "rate_limit_error",
code: "rate_limited"
}
}), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
if (result.text) {
totalContent += result.text;
}
}
// Build non-streaming response
const estimatedPromptTokens = 10;
const estimatedCompletionTokens = Math.max(1, Math.floor(totalContent.length / 4));
const completion = {
id: responseId,
object: "chat.completion",
created,
model,
choices: [{
index: 0,
message: {
role: "assistant",
content: totalContent
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: estimatedPromptTokens,
completion_tokens: estimatedCompletionTokens,
total_tokens: estimatedPromptTokens + estimatedCompletionTokens
}
};
return new Response(JSON.stringify(completion), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
/**
* Transform ConnectRPC protobuf buffer to SSE Response
*/
transformProtobufToSSE(buffer, model) {
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
// Parse all frames from buffer
const chunks = [];
let offset = 0;
let totalContent = "";
while (offset < buffer.length) {
if (offset + 5 > buffer.length) break;
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
if (offset + 5 + length > buffer.length) break;
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
// Decompress if gzip (flags 0x01 or 0x03)
if (flags === 0x01 || flags === 0x03) {
try {
const zlib = require("zlib");
payload = zlib.gunzipSync(payload);
} catch {
continue;
}
}
// Check if payload is JSON error (ConnectRPC error format)
try {
const text = payload.toString("utf-8");
if (text.startsWith("{") && text.includes('"error"')) {
const jsonError = JSON.parse(text);
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|| jsonError?.error?.message
|| "API Error";
return new Response(JSON.stringify({
error: {
message: errorMsg,
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
}
}), {
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
headers: { "Content-Type": "application/json" }
});
}
} catch {}
// Extract text or error from protobuf
const result = extractTextFromResponse(new Uint8Array(payload));
if (result.error) {
// Return error response
return new Response(JSON.stringify({
error: {
message: result.error,
type: "rate_limit_error",
code: "rate_limited"
}
}), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
if (result.text) {
totalContent += result.text;
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: chunks.length === 0
? { role: "assistant", content: result.text }
: { content: result.text },
finish_reason: null
}]
};
chunks.push(`data: ${JSON.stringify(chunk)}\n\n`);
}
}
// Add finish chunk
const estimatedTokens = Math.max(1, Math.floor(totalContent.length / 4));
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: estimatedTokens,
total_tokens: estimatedTokens
}
};
chunks.push(`data: ${JSON.stringify(finishChunk)}\n\n`);
chunks.push("data: [DONE]\n\n");
return new Response(chunks.join(""), {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
});
}
/**
* Cursor doesn't support standard OAuth refresh
*/
async refreshCredentials() {
return null;
}
}
export default CursorExecutor;

View File

@@ -3,6 +3,7 @@ import { GeminiCLIExecutor } from "./gemini-cli.js";
import { GithubExecutor } from "./github.js";
import { KiroExecutor } from "./kiro.js";
import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js";
import { DefaultExecutor } from "./default.js";
const executors = {
@@ -10,7 +11,9 @@ const executors = {
"gemini-cli": new GeminiCLIExecutor(),
github: new GithubExecutor(),
kiro: new KiroExecutor(),
codex: new CodexExecutor()
codex: new CodexExecutor(),
cursor: new CursorExecutor(),
cu: new CursorExecutor() // Alias for cursor
};
const defaultCache = new Map();
@@ -31,4 +34,5 @@ export { GeminiCLIExecutor } from "./gemini-cli.js";
export { GithubExecutor } from "./github.js";
export { KiroExecutor } from "./kiro.js";
export { CodexExecutor } from "./codex.js";
export { CursorExecutor } from "./cursor.js";
export { DefaultExecutor } from "./default.js";

View File

@@ -8,11 +8,13 @@ const ALIAS_TO_PROVIDER_ID = {
ag: "antigravity",
gh: "github",
kr: "kiro",
cu: "cursor",
// API Key providers (alias = id)
openai: "openai",
anthropic: "anthropic",
gemini: "gemini",
openrouter: "openrouter",
cursor: "cursor",
};
/**

View File

@@ -0,0 +1,133 @@
/**
* Cursor Checksum Utility (Jyh Cipher)
*
* Generates the x-cursor-checksum header required for Cursor API authentication.
* Based on the JavaScript implementation from Cursor IDE.
*/
import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
/**
* Generate SHA-256 hash like generateHashed64Hex
* @param {string} input - Input string
* @param {string} salt - Optional salt
* @returns {string} - 64-character hex string
*/
export function generateHashed64Hex(input, salt = "") {
return crypto.createHash("sha256").update(input + salt).digest("hex");
}
/**
* Generate session ID using UUID v5 with DNS namespace
* @param {string} authToken - Auth token
* @returns {string} - UUID string
*/
export function generateSessionId(authToken) {
return uuidv5(authToken, uuidv5.DNS);
}
/**
* Generate cursor checksum (Jyh cipher)
*
* Algorithm:
* 1. Get Unix timestamp in specific format
* 2. XOR each byte with key (starting 165)
* 3. Update key: key = (key + byte) & 0xFF
* 4. URL-safe base64 encode
* 5. Format: {base64_encoded}{machineId}
*
* @param {string} machineId - Machine ID from Cursor storage or generated
* @returns {string} - Checksum string
*/
export function generateCursorChecksum(machineId) {
// Math.floor(Date.now() / 1e6) - same as Python implementation
const timestamp = Math.floor(Date.now() / 1000000);
// Create byte array from timestamp (6 bytes, big-endian)
const byteArray = new Uint8Array([
(timestamp >> 40) & 0xFF,
(timestamp >> 32) & 0xFF,
(timestamp >> 24) & 0xFF,
(timestamp >> 16) & 0xFF,
(timestamp >> 8) & 0xFF,
timestamp & 0xFF
]);
// Jyh cipher obfuscation
let t = 165;
for (let i = 0; i < byteArray.length; i++) {
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF;
t = byteArray[i];
}
// URL-safe base64 encode (without padding)
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let encoded = "";
for (let i = 0; i < byteArray.length; i += 3) {
const a = byteArray[i];
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
encoded += alphabet[a >> 2];
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
if (i + 1 < byteArray.length) {
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
}
if (i + 2 < byteArray.length) {
encoded += alphabet[c & 63];
}
}
return `${encoded}${machineId}`;
}
/**
* Build all Cursor API headers
*
* @param {string} accessToken - Bearer token
* @param {string} machineId - Machine ID (or will be generated from token)
* @param {boolean} ghostMode - Enable ghost mode (privacy)
* @returns {Object} - Headers object
*/
export function buildCursorHeaders(accessToken, machineId = null, ghostMode = true) {
// Clean token if it has prefix
const cleanToken = accessToken.includes("::")
? accessToken.split("::")[1]
: accessToken;
// Generate machine ID if not provided
const effectiveMachineId = machineId || generateHashed64Hex(cleanToken, "machineId");
// Generate derived values
const sessionId = generateSessionId(cleanToken);
const clientKey = generateHashed64Hex(cleanToken);
const checksum = generateCursorChecksum(effectiveMachineId);
return {
"Authorization": `Bearer ${cleanToken}`,
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
"x-client-key": clientKey,
"x-cursor-checksum": checksum,
"x-cursor-client-version": "1.1.3",
"x-cursor-config-version": crypto.randomUUID(),
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
"x-ghost-mode": ghostMode ? "true" : "false",
"x-request-id": crypto.randomUUID(),
"x-session-id": sessionId,
"Host": "api2.cursor.sh"
};
}
export default {
generateCursorChecksum,
buildCursorHeaders,
generateHashed64Hex,
generateSessionId
};

View File

@@ -0,0 +1,539 @@
/**
* Cursor Protobuf Encoding/Decoding Utility
*
* Implements protobuf wire format encoding for Cursor API requests
* and decoding for streaming responses.
*
* Wire format reference:
* - Wire type 0: Varint (int32, int64, uint32, uint64, bool, enum)
* - Wire type 2: Length-delimited (string, bytes, embedded messages)
*/
import { v4 as uuidv4 } from "uuid";
import zlib from "zlib";
// =============================================================================
// Encoding Functions
// =============================================================================
/**
* Encode an integer as a varint
* @param {number} value - Integer to encode
* @returns {Uint8Array} - Encoded bytes
*/
export function encodeVarint(value) {
const bytes = [];
while (value >= 0x80) {
bytes.push((value & 0x7F) | 0x80);
value >>>= 7;
}
bytes.push(value & 0x7F);
return new Uint8Array(bytes);
}
/**
* Encode a protobuf field
* @param {number} fieldNum - Field number
* @param {number} wireType - Wire type (0=varint, 2=length-delimited)
* @param {*} value - Value to encode
* @returns {Uint8Array} - Encoded bytes
*/
export function encodeField(fieldNum, wireType, value) {
const tag = (fieldNum << 3) | wireType;
const tagBytes = encodeVarint(tag);
if (wireType === 0) {
// Varint
const valueBytes = encodeVarint(value);
const result = new Uint8Array(tagBytes.length + valueBytes.length);
result.set(tagBytes);
result.set(valueBytes, tagBytes.length);
return result;
} else if (wireType === 2) {
// Length-delimited (string, bytes, nested message)
let dataBytes;
if (typeof value === "string") {
dataBytes = new TextEncoder().encode(value);
} else if (value instanceof Uint8Array) {
dataBytes = value;
} else if (Buffer.isBuffer(value)) {
dataBytes = new Uint8Array(value);
} else {
dataBytes = new Uint8Array(0);
}
const lengthBytes = encodeVarint(dataBytes.length);
const result = new Uint8Array(tagBytes.length + lengthBytes.length + dataBytes.length);
result.set(tagBytes);
result.set(lengthBytes, tagBytes.length);
result.set(dataBytes, tagBytes.length + lengthBytes.length);
return result;
}
return new Uint8Array(0);
}
/**
* Concatenate multiple Uint8Arrays
* @param {...Uint8Array} arrays - Arrays to concatenate
* @returns {Uint8Array} - Concatenated array
*/
function concatArrays(...arrays) {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Encode a Message (conversation message)
*
* Schema:
* string content = 1;
* int32 role = 2;
* string messageId = 13;
* int32 chatModeEnum = 47; (only for user)
*/
export function encodeMessage(content, role, messageId, chatModeEnum = null) {
const parts = [];
// Field 1: content (string)
parts.push(encodeField(1, 2, content));
// Field 2: role (int32) - 1=user, 2=assistant
parts.push(encodeField(2, 0, role));
// Field 13: messageId (string)
parts.push(encodeField(13, 2, messageId));
// Field 47: chatModeEnum (only for user messages)
if (chatModeEnum !== null) {
parts.push(encodeField(47, 0, chatModeEnum));
}
return concatArrays(...parts);
}
/**
* Encode Instruction message
* Schema: string instruction = 1;
*/
export function encodeInstruction(instructionText) {
if (!instructionText) return new Uint8Array(0);
return encodeField(1, 2, instructionText);
}
/**
* Encode Model message
* Schema:
* string name = 1;
* bytes empty = 4;
*/
export function encodeModel(modelName) {
return concatArrays(
encodeField(1, 2, modelName),
encodeField(4, 2, new Uint8Array(0))
);
}
/**
* Encode CursorSetting message
*/
export function encodeCursorSetting() {
// Unknown6 nested message
const unknown6 = concatArrays(
encodeField(1, 2, new Uint8Array(0)),
encodeField(2, 2, new Uint8Array(0))
);
return concatArrays(
encodeField(1, 2, "cursor\\aisettings"),
encodeField(3, 2, new Uint8Array(0)),
encodeField(6, 2, unknown6),
encodeField(8, 0, 1),
encodeField(9, 0, 1)
);
}
/**
* Encode Metadata message
*/
export function encodeMetadata() {
return concatArrays(
encodeField(1, 2, process.platform || "linux"),
encodeField(2, 2, process.arch || "x64"),
encodeField(3, 2, process.version || "v20.0.0"),
encodeField(4, 2, process.cwd?.() || "/"),
encodeField(5, 2, new Date().toISOString())
);
}
/**
* Encode MessageId message
*/
export function encodeMessageId(messageId, role, summaryId = null) {
const parts = [
encodeField(1, 2, messageId),
];
if (summaryId) {
parts.push(encodeField(2, 2, summaryId));
}
parts.push(encodeField(3, 0, role));
return concatArrays(...parts);
}
/**
* Encode the Request message (inner request)
*/
export function encodeRequest(messages, modelName) {
const parts = [];
const formattedMessages = [];
const messageIds = [];
// Format messages
for (const msg of messages) {
const role = msg.role === "user" ? 1 : 2;
const msgId = uuidv4();
formattedMessages.push({
content: msg.content,
role,
messageId: msgId,
chatModeEnum: role === 1 ? 1 : null // Only for user messages
});
messageIds.push({ messageId: msgId, role });
}
// Field 1: repeated Message messages
for (const fm of formattedMessages) {
const messageBytes = encodeMessage(fm.content, fm.role, fm.messageId, fm.chatModeEnum);
parts.push(encodeField(1, 2, messageBytes));
}
// Field 2: unknown2 = 1
parts.push(encodeField(2, 0, 1));
// Field 3: Instruction
parts.push(encodeField(3, 2, encodeInstruction("")));
// Field 4: unknown4 = 1
parts.push(encodeField(4, 0, 1));
// Field 5: Model - always send, even for "default"
if (modelName) {
parts.push(encodeField(5, 2, encodeModel(modelName)));
}
// Field 8: webTool = ""
parts.push(encodeField(8, 2, ""));
// Field 13: unknown13 = 1
parts.push(encodeField(13, 0, 1));
// Field 15: CursorSetting
parts.push(encodeField(15, 2, encodeCursorSetting()));
// Field 19: unknown19 = 1
parts.push(encodeField(19, 0, 1));
// Field 23: conversationId
parts.push(encodeField(23, 2, uuidv4()));
// Field 26: Metadata
parts.push(encodeField(26, 2, encodeMetadata()));
// Field 27: unknown27 = 0
parts.push(encodeField(27, 0, 0));
// Field 30: repeated MessageId
for (const mid of messageIds) {
parts.push(encodeField(30, 2, encodeMessageId(mid.messageId, mid.role)));
}
// Field 35: largeContext = 0
parts.push(encodeField(35, 0, 0));
// Field 38: unknown38 = 0
parts.push(encodeField(38, 0, 0));
// Field 46: chatModeEnum = 1
parts.push(encodeField(46, 0, 1));
// Field 47: unknown47 = ""
parts.push(encodeField(47, 2, ""));
// Field 48-51, 53
parts.push(encodeField(48, 0, 0));
parts.push(encodeField(49, 0, 0));
parts.push(encodeField(51, 0, 0));
parts.push(encodeField(53, 0, 1));
// Field 54: chatMode = "Ask"
parts.push(encodeField(54, 2, "Ask"));
return concatArrays(...parts);
}
/**
* Build the full StreamUnifiedChatWithToolsRequest
*/
export function buildChatRequest(messages, modelName) {
// Field 1: Request request
const requestBytes = encodeRequest(messages, modelName);
return encodeField(1, 2, requestBytes);
}
/**
* Wrap payload with ConnectRPC frame header
*
* Frame format: [flags:1][length:4][payload]
* - flags: 0x00 = uncompressed, 0x01 = gzip compressed
* - length: big-endian 32-bit length
*
* @param {Uint8Array} payload - Protobuf payload
* @param {boolean} compress - Whether to gzip compress (for messages >= 3)
* @returns {Uint8Array} - Framed data
*/
export function wrapConnectRPCFrame(payload, compress = false) {
let finalPayload = payload;
let flags = 0x00;
if (compress) {
finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload)));
flags = 0x01;
}
// Create frame: [flags:1][length:4][payload]
const frame = new Uint8Array(5 + finalPayload.length);
frame[0] = flags;
// Big-endian length
const length = finalPayload.length;
frame[1] = (length >> 24) & 0xFF;
frame[2] = (length >> 16) & 0xFF;
frame[3] = (length >> 8) & 0xFF;
frame[4] = length & 0xFF;
frame.set(finalPayload, 5);
return frame;
}
/**
* Generate complete Cursor request body
* @param {Array} messages - Array of {role, content} messages
* @param {string} modelName - Model name
* @returns {Uint8Array} - Complete request body
*/
export function generateCursorBody(messages, modelName) {
const protobuf = buildChatRequest(messages, modelName);
// Compress if >= 3 messages
const shouldCompress = messages.length >= 3;
return wrapConnectRPCFrame(protobuf, shouldCompress);
}
// =============================================================================
// Decoding Functions
// =============================================================================
/**
* Decode a varint from buffer
* @param {Uint8Array} buffer - Input buffer
* @param {number} offset - Start offset
* @returns {[number, number]} - [value, newOffset]
*/
export function decodeVarint(buffer, offset) {
let result = 0;
let shift = 0;
let pos = offset;
while (pos < buffer.length) {
const b = buffer[pos];
result |= (b & 0x7F) << shift;
pos++;
if (!(b & 0x80)) break;
shift += 7;
}
return [result, pos];
}
/**
* Decode a single protobuf field
* @param {Uint8Array} buffer - Input buffer
* @param {number} offset - Start offset
* @returns {[number, number, any, number]} - [fieldNum, wireType, value, newOffset]
*/
export function decodeField(buffer, offset) {
if (offset >= buffer.length) {
return [null, null, null, offset];
}
const [tag, pos1] = decodeVarint(buffer, offset);
const fieldNum = tag >> 3;
const wireType = tag & 0x07;
let value;
let pos = pos1;
if (wireType === 0) {
// Varint
[value, pos] = decodeVarint(buffer, pos);
} else if (wireType === 2) {
// Length-delimited
const [length, pos2] = decodeVarint(buffer, pos);
value = buffer.slice(pos2, pos2 + length);
pos = pos2 + length;
} else if (wireType === 1) {
// Fixed64
value = buffer.slice(pos, pos + 8);
pos += 8;
} else if (wireType === 5) {
// Fixed32
value = buffer.slice(pos, pos + 4);
pos += 4;
} else {
value = null;
}
return [fieldNum, wireType, value, pos];
}
/**
* Decode all fields from a protobuf message
* @param {Uint8Array} data - Protobuf data
* @returns {Map<number, Array>} - Map of fieldNum -> [{wireType, value}]
*/
export function decodeMessage(data) {
const fields = new Map();
let pos = 0;
while (pos < data.length) {
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
if (fieldNum === null) break;
if (!fields.has(fieldNum)) {
fields.set(fieldNum, []);
}
fields.get(fieldNum).push({ wireType, value });
pos = newPos;
}
return fields;
}
/**
* Parse ConnectRPC frame
* @param {Uint8Array} buffer - Input buffer
* @returns {{flags: number, length: number, payload: Uint8Array, consumed: number} | null}
*/
export function parseConnectRPCFrame(buffer) {
if (buffer.length < 5) return null;
const flags = buffer[0];
const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
if (buffer.length < 5 + length) return null;
let payload = buffer.slice(5, 5 + length);
// Decompress if gzip flag is set
if (flags === 0x01) {
try {
payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload)));
} catch {
// Decompression failed, return raw
}
}
return {
flags,
length,
payload,
consumed: 5 + length
};
}
/**
* Extract text content or error from response protobuf
*
* Response structure (from cursor-grpc/server_full.proto):
*
* message StreamUnifiedChatResponseWithTools {
* oneof response {
* ClientSideToolV2Call client_side_tool_v2_call = 1;
* StreamUnifiedChatResponse stream_unified_chat_response = 2;
* }
* }
*
* message StreamUnifiedChatResponse {
* string text = 1; // <-- THE TEXT WE NEED
* }
*
* @param {Uint8Array} payload - Decoded protobuf payload
* @returns {{text: string|null, error: string|null}} - Extracted content
*/
export function extractTextFromResponse(payload) {
try {
const fields = decodeMessage(payload);
// Field 2 = StreamUnifiedChatResponse (contains the text)
if (fields.has(2)) {
for (const { wireType, value } of fields.get(2)) {
if (wireType === 2) {
// Decode nested StreamUnifiedChatResponse
try {
const nested = decodeMessage(value);
// Field 1 = text (string)
if (nested.has(1)) {
for (const { wireType: nwt, value: nv } of nested.get(1)) {
if (nwt === 2) {
try {
const text = new TextDecoder().decode(nv);
// Return any non-empty text
if (text && text.length > 0) {
return { text, error: null };
}
} catch {}
}
}
}
} catch {}
}
}
}
// Field 1 could be ClientSideToolV2Call (skip for now)
// Field 3 could be ConversationSummary (skip for now)
return { text: null, error: null };
} catch {
return { text: null, error: null };
}
}
export default {
// Encoding
encodeVarint,
encodeField,
encodeMessage,
buildChatRequest,
wrapConnectRPCFrame,
generateCursorBody,
// Decoding
decodeVarint,
decodeField,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse
};

View File

@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, Toggle, Select } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -486,6 +486,12 @@ export default function ProviderDetailPage() {
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "cursor" ? (
<CursorAuthModal
isOpen={showOAuthModal}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : (
<OAuthModal
isOpen={showOAuthModal}

View File

@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { CursorService } from "@/lib/oauth/services/cursor";
import { createProviderConnection, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
/**
* POST /api/oauth/cursor/import
* Import and validate access token from Cursor IDE's local SQLite database
*
* Request body:
* - accessToken: string - Access token from cursorAuth/accessToken
* - machineId: string - Machine ID from storage.serviceMachineId
*/
export async function POST(request) {
try {
const { accessToken, machineId } = await request.json();
if (!accessToken || typeof accessToken !== "string") {
return NextResponse.json(
{ error: "Access token is required" },
{ status: 400 }
);
}
if (!machineId || typeof machineId !== "string") {
return NextResponse.json(
{ error: "Machine ID is required" },
{ status: 400 }
);
}
const cursorService = new CursorService();
// Validate token by making API call
const tokenData = await cursorService.validateImportToken(
accessToken.trim(),
machineId.trim()
);
// Try to extract user info from token
const userInfo = cursorService.extractUserInfo(tokenData.accessToken);
// Save to database
const connection = await createProviderConnection({
provider: "cursor",
authType: "oauth",
accessToken: tokenData.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
email: userInfo?.email || null,
providerSpecificData: {
machineId: tokenData.machineId,
authMethod: "imported",
provider: "Imported",
userId: userInfo?.userId,
},
testStatus: "active",
});
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
},
});
} catch (error) {
console.log("Cursor import token error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* GET /api/oauth/cursor/import
* Get instructions for importing Cursor token
*/
export async function GET() {
const cursorService = new CursorService();
const instructions = cursorService.getTokenStorageInstructions();
return NextResponse.json({
provider: "cursor",
method: "import_token",
instructions,
requiredFields: [
{
name: "accessToken",
label: "Access Token",
description: "From cursorAuth/accessToken in state.vscdb",
type: "textarea",
},
{
name: "machineId",
label: "Machine ID",
description: "From storage.serviceMachineId in state.vscdb",
type: "text",
},
],
});
}
/**
* Sync to Cloud if enabled
*/
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud after Cursor import:", error);
}
}

View File

@@ -142,6 +142,34 @@ export const KIRO_CONFIG = {
authMethods: ["builder-id", "idc", "google", "github", "import"],
};
// Cursor OAuth Configuration (Import Token from Cursor IDE)
// Cursor stores credentials in SQLite database: state.vscdb
// Keys: cursorAuth/accessToken, storage.serviceMachineId
export const CURSOR_CONFIG = {
// API endpoints
apiEndpoint: "https://api2.cursor.sh",
chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData",
// Additional endpoints
api3Endpoint: "https://api3.cursor.sh", // Telemetry
agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode
// Client metadata
clientVersion: "0.48.6",
clientType: "ide",
// Token storage locations (for user reference)
tokenStoragePaths: {
linux: "~/.config/Cursor/User/globalStorage/state.vscdb",
macos: "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb",
windows: "%APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb",
},
// Database keys
dbKeys: {
accessToken: "cursorAuth/accessToken",
machineId: "storage.serviceMachineId",
},
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -156,4 +184,5 @@ export const PROVIDERS = {
OPENAI: "openai",
GITHUB: "github",
KIRO: "kiro",
CURSOR: "cursor",
};

View File

@@ -13,6 +13,7 @@ import {
ANTIGRAVITY_CONFIG,
GITHUB_CONFIG,
KIRO_CONFIG,
CURSOR_CONFIG,
} from "./constants/oauth";
// Provider configurations
@@ -656,6 +657,22 @@ const PROVIDERS = {
},
}),
},
cursor: {
config: CURSOR_CONFIG,
flowType: "import_token",
// Cursor uses import token flow - tokens are extracted from local SQLite database
// No OAuth flow needed, handled by /api/oauth/cursor/import route
mapTokens: (tokens) => ({
accessToken: tokens.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresIn: tokens.expiresIn || 86400,
providerSpecificData: {
machineId: tokens.machineId,
authMethod: "imported",
},
}),
},
};
/**

View File

@@ -0,0 +1,179 @@
import { CURSOR_CONFIG } from "../constants/oauth.js";
/**
* Cursor IDE OAuth Service
* Supports Import Token method from Cursor IDE's local SQLite database
*
* Token Location:
* - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
* - macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
* - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb
*
* Database Keys:
* - cursorAuth/accessToken: The access token
* - storage.serviceMachineId: Machine ID for checksum
*/
export class CursorService {
constructor() {
this.config = CURSOR_CONFIG;
}
/**
* Generate Cursor checksum (jyh cipher)
* Algorithm: XOR timestamp bytes with rolling key (initial 165), then base64 encode
* Format: {encoded_timestamp},{machineId}
*/
generateChecksum(machineId) {
const timestamp = Math.floor(Date.now() / 1000).toString();
let key = 165;
const encoded = [];
for (let i = 0; i < timestamp.length; i++) {
const charCode = timestamp.charCodeAt(i);
encoded.push(charCode ^ key);
key = (key + charCode) & 0xff; // Rolling key update
}
const base64Encoded = Buffer.from(encoded).toString("base64");
return `${base64Encoded},${machineId}`;
}
/**
* Build request headers for Cursor API
*/
buildHeaders(accessToken, machineId, ghostMode = false) {
const checksum = this.generateChecksum(machineId);
return {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/connect+proto",
"Connect-Protocol-Version": "1",
"x-cursor-client-version": this.config.clientVersion,
"x-cursor-client-type": this.config.clientType,
"x-cursor-client-os": this.detectOS(),
"x-cursor-client-arch": this.detectArch(),
"x-cursor-client-device-type": "desktop",
"x-cursor-checksum": checksum,
"x-ghost-mode": ghostMode ? "true" : "false",
};
}
/**
* Detect OS for headers
*/
detectOS() {
if (typeof process !== "undefined") {
const platform = process.platform;
if (platform === "win32") return "windows";
if (platform === "darwin") return "macos";
return "linux";
}
return "linux";
}
/**
* Detect architecture for headers
*/
detectArch() {
if (typeof process !== "undefined") {
const arch = process.arch;
if (arch === "x64") return "x86_64";
if (arch === "arm64") return "aarch64";
return arch;
}
return "x86_64";
}
/**
* Validate and import token from Cursor IDE
* Note: We skip API validation because Cursor API uses complex protobuf format.
* Token will be validated when actually used for requests.
* @param {string} accessToken - Access token from state.vscdb
* @param {string} machineId - Machine ID from state.vscdb
*/
async validateImportToken(accessToken, machineId) {
// Basic validation
if (!accessToken || typeof accessToken !== "string") {
throw new Error("Access token is required");
}
if (!machineId || typeof machineId !== "string") {
throw new Error("Machine ID is required");
}
// Token format validation (Cursor tokens are typically long strings)
if (accessToken.length < 50) {
throw new Error("Invalid token format. Token appears too short.");
}
// Machine ID format validation (should be UUID-like)
const uuidRegex = /^[a-f0-9-]{32,}$/i;
if (!uuidRegex.test(machineId.replace(/-/g, ""))) {
throw new Error("Invalid machine ID format. Expected UUID format.");
}
// Note: We don't validate against API because Cursor uses complex protobuf.
// Token will be validated when used for actual requests.
return {
accessToken,
machineId,
expiresIn: 86400, // Cursor tokens typically last 24 hours
authMethod: "imported",
};
}
/**
* Extract user info from token if possible
* Cursor tokens may contain encoded user info
*/
extractUserInfo(accessToken) {
try {
// Try to decode as JWT
const parts = accessToken.split(".");
if (parts.length === 3) {
let payload = parts[1];
while (payload.length % 4) {
payload += "=";
}
const decoded = JSON.parse(
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
);
return {
email: decoded.email || decoded.sub,
userId: decoded.sub || decoded.user_id,
};
}
} catch {
// Token is not a JWT, that's okay
}
return null;
}
/**
* Get token storage path instructions for user
*/
getTokenStorageInstructions() {
return {
title: "How to get your Cursor token",
steps: [
"1. Open Cursor IDE and make sure you're logged in",
"2. Find the state.vscdb file:",
` - Linux: ${this.config.tokenStoragePaths.linux}`,
` - macOS: ${this.config.tokenStoragePaths.macos}`,
` - Windows: ${this.config.tokenStoragePaths.windows}`,
"3. Open the database with SQLite browser or CLI:",
" sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='cursorAuth/accessToken'\"",
"4. Also get the machine ID:",
" sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='storage.serviceMachineId'\"",
"5. Paste both values in the form below",
],
alternativeMethod: [
"Or use this one-liner to get both values:",
"sqlite3 state.vscdb \"SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')\"",
],
};
}
}

View File

@@ -12,4 +12,5 @@ export { AntigravityService } from "./antigravity.js";
export { OpenAIService } from "./openai.js";
export { GitHubService } from "./github.js";
export { KiroService } from "./kiro.js";
export { CursorService } from "./cursor.js";

View File

@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
/**
* Cursor Auth Modal
* Import token from Cursor IDE's local SQLite database
*
* Token Location:
* - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
* - macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
* - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb
*
* Database Keys:
* - cursorAuth/accessToken: The access token
* - storage.serviceMachineId: Machine ID for checksum
*/
export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
const [accessToken, setAccessToken] = useState("");
const [machineId, setMachineId] = useState("");
const [error, setError] = useState(null);
const [importing, setImporting] = useState(false);
const { copied, copy } = useCopyToClipboard();
const handleImportToken = async () => {
if (!accessToken.trim()) {
setError("Please enter an access token");
return;
}
if (!machineId.trim()) {
setError("Please enter a machine ID");
return;
}
setImporting(true);
setError(null);
try {
const res = await fetch("/api/oauth/cursor/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accessToken: accessToken.trim(),
machineId: machineId.trim(),
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Import failed");
}
// Success - close modal and trigger refresh
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
};
const linuxCommand = `sqlite3 ~/.config/Cursor/User/globalStorage/state.vscdb "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`;
const macCommand = `sqlite3 ~/Library/Application\\ Support/Cursor/User/globalStorage/state.vscdb "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`;
return (
<Modal isOpen={isOpen} title="Connect Cursor IDE" onClose={onClose} size="lg">
<div className="flex flex-col gap-4">
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">info</span>
<div className="flex-1 text-sm">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
Prerequisites
</p>
<p className="text-blue-800 dark:text-blue-200">
Make sure you are logged in to Cursor IDE first. Tokens are stored in the local SQLite database.
</p>
</div>
</div>
</div>
{/* Instructions */}
<div className="space-y-3">
<p className="text-sm font-medium">How to get your tokens:</p>
<div className="bg-sidebar/50 p-3 rounded-lg space-y-2">
<p className="text-xs text-text-muted">Linux / macOS:</p>
<div className="flex items-start gap-2">
<code className="text-xs bg-sidebar px-2 py-1 rounded flex-1 overflow-x-auto whitespace-pre">
{linuxCommand}
</code>
<button
onClick={() => copy(linuxCommand, "linux-cmd")}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary flex-shrink-0"
title="Copy command"
>
<span className="material-symbols-outlined text-sm">
{copied === "linux-cmd" ? "check" : "content_copy"}
</span>
</button>
</div>
</div>
<div className="text-xs text-text-muted">
<p className="mb-1">Database locations:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Linux: <code className="bg-sidebar px-1 rounded">~/.config/Cursor/User/globalStorage/state.vscdb</code></li>
<li>macOS: <code className="bg-sidebar px-1 rounded">~/Library/Application Support/Cursor/User/globalStorage/state.vscdb</code></li>
<li>Windows: <code className="bg-sidebar px-1 rounded">%APPDATA%\Cursor\User\globalStorage\state.vscdb</code></li>
</ul>
</div>
</div>
{/* Access Token Input */}
<div>
<label className="block text-sm font-medium mb-2">
Access Token <span className="text-red-500">*</span>
</label>
<textarea
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="Paste your access token here..."
rows={3}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
<p className="text-xs text-text-muted mt-1">
From key: <code className="bg-sidebar px-1 rounded">cursorAuth/accessToken</code>
</p>
</div>
{/* Machine ID Input */}
<div>
<label className="block text-sm font-medium mb-2">
Machine ID <span className="text-red-500">*</span>
</label>
<Input
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
From key: <code className="bg-sidebar px-1 rounded">storage.serviceMachineId</code>
</p>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button
onClick={handleImportToken}
fullWidth
disabled={importing || !accessToken.trim() || !machineId.trim()}
>
{importing ? "Importing..." : "Import Token"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
CursorAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View File

@@ -21,6 +21,7 @@ export { default as RequestLogger } from "./RequestLogger";
export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
// Layouts

View File

@@ -10,6 +10,7 @@ export const OAUTH_PROVIDERS = {
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
};
export const APIKEY_PROVIDERS = {