cursor-oauth-opencode 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,50 @@
1
1
  import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
2
- import { getCursorModels } from "./models";
3
- import { startProxy } from "./proxy";
2
+ import { getCachedCursorModels, getCursorModels } from "./models";
3
+ import { startProxy, updateProxyModels } from "./proxy";
4
4
  const CURSOR_PROVIDER_ID = "cursor";
5
5
  /**
6
6
  * OpenCode plugin that provides Cursor authentication and model access.
7
7
  * Register in opencode.json: { "plugin": ["cursor-oauth-opencode"] }
8
8
  */
9
9
  export const CursorAuthPlugin = async (input) => {
10
+ async function persistRefreshedAuth(refreshed) {
11
+ await input.client.auth.set({
12
+ path: { id: CURSOR_PROVIDER_ID },
13
+ body: {
14
+ type: "oauth",
15
+ refresh: refreshed.refresh,
16
+ access: refreshed.access,
17
+ expires: refreshed.expires,
18
+ },
19
+ });
20
+ }
21
+ async function refreshCursorTokenWithTimeout(refreshToken, timeoutMs) {
22
+ let timeout;
23
+ try {
24
+ return await Promise.race([
25
+ refreshCursorToken(refreshToken),
26
+ new Promise((_, reject) => {
27
+ timeout = setTimeout(() => reject(new Error("Cursor token refresh timed out")), timeoutMs);
28
+ }),
29
+ ]);
30
+ }
31
+ finally {
32
+ if (timeout)
33
+ clearTimeout(timeout);
34
+ }
35
+ }
36
+ function refreshModelsInBackground(provider, port, accessToken) {
37
+ if (!accessToken)
38
+ return;
39
+ void getCursorModels(accessToken, { timeoutMs: 2_000 })
40
+ .then((models) => {
41
+ updateProxyModels(models);
42
+ if (provider) {
43
+ provider.models = buildCursorProviderModels(models, port);
44
+ }
45
+ })
46
+ .catch(() => { });
47
+ }
10
48
  return {
11
49
  auth: {
12
50
  provider: CURSOR_PROVIDER_ID,
@@ -14,22 +52,8 @@ export const CursorAuthPlugin = async (input) => {
14
52
  const auth = await getAuth();
15
53
  if (!auth || auth.type !== "oauth")
16
54
  return {};
17
- // Ensure we have a valid access token, refreshing if expired
18
- let accessToken = auth.access;
19
- if (!accessToken || auth.expires < Date.now()) {
20
- const refreshed = await refreshCursorToken(auth.refresh);
21
- await input.client.auth.set({
22
- path: { id: CURSOR_PROVIDER_ID },
23
- body: {
24
- type: "oauth",
25
- refresh: refreshed.refresh,
26
- access: refreshed.access,
27
- expires: refreshed.expires,
28
- },
29
- });
30
- accessToken = refreshed.access;
31
- }
32
- const models = await getCursorModels(accessToken);
55
+ const accessToken = auth.access;
56
+ const models = getCachedCursorModels(accessToken);
33
57
  const port = await startProxy(async () => {
34
58
  const currentAuth = await getAuth();
35
59
  if (currentAuth.type !== "oauth") {
@@ -37,15 +61,7 @@ export const CursorAuthPlugin = async (input) => {
37
61
  }
38
62
  if (!currentAuth.access || currentAuth.expires < Date.now()) {
39
63
  const refreshed = await refreshCursorToken(currentAuth.refresh);
40
- await input.client.auth.set({
41
- path: { id: CURSOR_PROVIDER_ID },
42
- body: {
43
- type: "oauth",
44
- refresh: refreshed.refresh,
45
- access: refreshed.access,
46
- expires: refreshed.expires,
47
- },
48
- });
64
+ await persistRefreshedAuth(refreshed);
49
65
  return refreshed.access;
50
66
  }
51
67
  return currentAuth.access;
@@ -53,6 +69,17 @@ export const CursorAuthPlugin = async (input) => {
53
69
  if (provider) {
54
70
  provider.models = buildCursorProviderModels(models, port);
55
71
  }
72
+ if (accessToken && auth.expires >= Date.now()) {
73
+ refreshModelsInBackground(provider, port, accessToken);
74
+ }
75
+ else if (auth.refresh) {
76
+ void refreshCursorTokenWithTimeout(auth.refresh, 1_500)
77
+ .then(async (refreshed) => {
78
+ await persistRefreshedAuth(refreshed);
79
+ refreshModelsInBackground(provider, port, refreshed.access);
80
+ })
81
+ .catch(() => { });
82
+ }
56
83
  return {
57
84
  baseURL: `http://localhost:${port}/v1`,
58
85
  apiKey: "cursor-proxy",
package/dist/models.d.ts CHANGED
@@ -5,6 +5,10 @@ export interface CursorModel {
5
5
  contextWindow: number;
6
6
  maxTokens: number;
7
7
  }
8
- export declare function getCursorModels(apiKey: string): Promise<CursorModel[]>;
8
+ export declare function getCachedCursorModels(apiKey?: string): CursorModel[];
9
+ export declare function getCursorModels(apiKey: string, options?: {
10
+ timeoutMs?: number;
11
+ allowStale?: boolean;
12
+ }): Promise<CursorModel[]>;
9
13
  /** @internal Test-only. */
10
14
  export declare function clearModelCache(): void;
package/dist/models.js CHANGED
@@ -4,12 +4,15 @@
4
4
  * when discovery fails.
5
5
  */
6
6
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
7
+ import { createHash } from "node:crypto";
7
8
  import { z } from "zod";
8
9
  import { callCursorUnaryRpc } from "./proxy";
9
10
  import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema, } from "./proto/agent_pb";
10
11
  const GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
11
12
  const DEFAULT_CONTEXT_WINDOW = 200_000;
12
13
  const DEFAULT_MAX_TOKENS = 64_000;
14
+ const MODEL_CACHE_TTL_MS = 10 * 60_000;
15
+ const DEFAULT_DISCOVERY_TIMEOUT_MS = 2_000;
13
16
  const CursorModelDetailsSchema = z.object({
14
17
  modelId: z.string(),
15
18
  displayName: z.string().optional().catch(undefined),
@@ -40,7 +43,7 @@ const FALLBACK_MODELS = [
40
43
  { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", reasoning: true, contextWindow: 1_000_000, maxTokens: 64_000 },
41
44
  { id: "grok-code-fast-1", name: "Grok Code Fast 1", reasoning: false, contextWindow: 128_000, maxTokens: 64_000 },
42
45
  ];
43
- async function fetchCursorUsableModels(apiKey) {
46
+ async function fetchCursorUsableModels(apiKey, timeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS) {
44
47
  try {
45
48
  const requestPayload = create(GetUsableModelsRequestSchema, {});
46
49
  const requestBody = toBinary(GetUsableModelsRequestSchema, requestPayload);
@@ -48,6 +51,7 @@ async function fetchCursorUsableModels(apiKey) {
48
51
  accessToken: apiKey,
49
52
  rpcPath: GET_USABLE_MODELS_PATH,
50
53
  requestBody,
54
+ timeoutMs,
51
55
  });
52
56
  if (response.timedOut || response.exitCode !== 0 || response.body.length === 0) {
53
57
  return null;
@@ -63,16 +67,60 @@ async function fetchCursorUsableModels(apiKey) {
63
67
  }
64
68
  }
65
69
  let cachedModels = null;
66
- export async function getCursorModels(apiKey) {
67
- if (cachedModels)
70
+ let cachedModelKey = "";
71
+ let cachedModelTime = 0;
72
+ let inFlightModelKey = "";
73
+ let inFlightModels = null;
74
+ function modelCacheKey(apiKey) {
75
+ return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
76
+ }
77
+ function isCacheFresh(cacheKey, now = Date.now()) {
78
+ return Boolean(cachedModels && cachedModelKey === cacheKey && now - cachedModelTime < MODEL_CACHE_TTL_MS);
79
+ }
80
+ export function getCachedCursorModels(apiKey) {
81
+ if (!apiKey)
82
+ return FALLBACK_MODELS;
83
+ const cacheKey = modelCacheKey(apiKey);
84
+ if (cachedModels && cachedModelKey === cacheKey)
85
+ return cachedModels;
86
+ return FALLBACK_MODELS;
87
+ }
88
+ export async function getCursorModels(apiKey, options = {}) {
89
+ const cacheKey = modelCacheKey(apiKey);
90
+ if (isCacheFresh(cacheKey))
91
+ return cachedModels;
92
+ if (inFlightModels && inFlightModelKey === cacheKey)
93
+ return inFlightModels;
94
+ inFlightModelKey = cacheKey;
95
+ inFlightModels = (async () => {
96
+ try {
97
+ const discovered = await fetchCursorUsableModels(apiKey, options.timeoutMs);
98
+ cachedModels = discovered && discovered.length > 0 ? discovered : FALLBACK_MODELS;
99
+ cachedModelKey = cacheKey;
100
+ cachedModelTime = Date.now();
101
+ }
102
+ catch {
103
+ if (!(options.allowStale ?? true) || cachedModelKey !== cacheKey || !cachedModels) {
104
+ cachedModels = FALLBACK_MODELS;
105
+ cachedModelKey = cacheKey;
106
+ cachedModelTime = Date.now();
107
+ }
108
+ }
109
+ finally {
110
+ inFlightModelKey = "";
111
+ inFlightModels = null;
112
+ }
68
113
  return cachedModels;
69
- const discovered = await fetchCursorUsableModels(apiKey);
70
- cachedModels = discovered && discovered.length > 0 ? discovered : FALLBACK_MODELS;
71
- return cachedModels;
114
+ })();
115
+ return inFlightModels;
72
116
  }
73
117
  /** @internal Test-only. */
74
118
  export function clearModelCache() {
75
119
  cachedModels = null;
120
+ cachedModelKey = "";
121
+ cachedModelTime = 0;
122
+ inFlightModelKey = "";
123
+ inFlightModels = null;
76
124
  }
77
125
  function decodeGetUsableModelsResponse(payload) {
78
126
  try {
package/dist/proxy.d.ts CHANGED
@@ -11,9 +11,17 @@ export declare function callCursorUnaryRpc(options: CursorUnaryRpcOptions): Prom
11
11
  timedOut: boolean;
12
12
  }>;
13
13
  export declare function getProxyPort(): number | undefined;
14
+ export declare function updateProxyModels(models: ReadonlyArray<{
15
+ id: string;
16
+ name: string;
17
+ }>): void;
14
18
  export declare function startProxy(getAccessToken: () => Promise<string>, models?: ReadonlyArray<{
15
19
  id: string;
16
20
  name: string;
17
21
  }>): Promise<number>;
18
22
  export declare function stopProxy(): void;
23
+ declare function normalizeMcpArgsForOpenCode(toolName: string, args: Record<string, unknown>): Record<string, unknown>;
24
+ export declare const __test: {
25
+ normalizeMcpArgsForOpenCode: typeof normalizeMcpArgsForOpenCode;
26
+ };
19
27
  export {};
package/dist/proxy.js CHANGED
@@ -170,6 +170,7 @@ export async function callCursorUnaryRpc(options) {
170
170
  }
171
171
  let proxyServer;
172
172
  let proxyPort;
173
+ let proxyStartPromise;
173
174
  let proxyAccessTokenProvider;
174
175
  let proxyModels = [];
175
176
  function buildOpenAIModelList(models) {
@@ -183,14 +184,25 @@ function buildOpenAIModelList(models) {
183
184
  export function getProxyPort() {
184
185
  return proxyPort;
185
186
  }
186
- export async function startProxy(getAccessToken, models = []) {
187
- proxyAccessTokenProvider = getAccessToken;
187
+ export function updateProxyModels(models) {
188
188
  proxyModels = models.map((model) => ({
189
189
  id: model.id,
190
190
  name: model.name,
191
191
  }));
192
+ }
193
+ export async function startProxy(getAccessToken, models = []) {
194
+ proxyAccessTokenProvider = getAccessToken;
195
+ updateProxyModels(models);
192
196
  if (proxyServer && proxyPort)
193
197
  return proxyPort;
198
+ if (proxyStartPromise)
199
+ return proxyStartPromise;
200
+ proxyStartPromise = startProxyInner().finally(() => {
201
+ proxyStartPromise = undefined;
202
+ });
203
+ return proxyStartPromise;
204
+ }
205
+ async function startProxyInner() {
194
206
  proxyServer = Bun.serve({
195
207
  port: 0,
196
208
  idleTimeout: 255, // max — Cursor responses can take 30s+
@@ -231,6 +243,7 @@ export function stopProxy() {
231
243
  proxyServer.stop();
232
244
  proxyServer = undefined;
233
245
  proxyPort = undefined;
246
+ proxyStartPromise = undefined;
234
247
  proxyAccessTokenProvider = undefined;
235
248
  proxyModels = [];
236
249
  }
@@ -392,6 +405,14 @@ function decodeMcpArgsMap(args) {
392
405
  }
393
406
  return decoded;
394
407
  }
408
+ function normalizeMcpArgsForOpenCode(toolName, args) {
409
+ if (toolName !== "read" || typeof args.filePath === "string")
410
+ return args;
411
+ if (typeof args.path !== "string")
412
+ return args;
413
+ const { path, ...rest } = args;
414
+ return { ...rest, filePath: path };
415
+ }
395
416
  function blobKey(blobId) {
396
417
  return Buffer.from(blobId).toString("hex");
397
418
  }
@@ -731,12 +752,13 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec) {
731
752
  }
732
753
  if (execCase === "mcpArgs") {
733
754
  const mcpArgs = execMsg.message.value;
734
- const decoded = decodeMcpArgsMap(mcpArgs.args ?? {});
755
+ const toolName = mcpArgs.toolName || mcpArgs.name;
756
+ const decoded = normalizeMcpArgsForOpenCode(toolName, decodeMcpArgsMap(mcpArgs.args ?? {}));
735
757
  onMcpExec({
736
758
  execId: execMsg.execId,
737
759
  execMsgId: execMsg.id,
738
760
  toolCallId: mcpArgs.toolCallId || crypto.randomUUID(),
739
- toolName: mcpArgs.toolName || mcpArgs.name,
761
+ toolName,
740
762
  decodedArgs: JSON.stringify(decoded),
741
763
  });
742
764
  return;
@@ -851,6 +873,9 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec) {
851
873
  // Unknown exec type — log and ignore
852
874
  console.error(`[proxy] unhandled exec: ${execCase}`);
853
875
  }
876
+ export const __test = {
877
+ normalizeMcpArgsForOpenCode,
878
+ };
854
879
  /** Send an exec client message back to Cursor. */
855
880
  function sendExecResult(execMsg, messageCase, value, sendFrame) {
856
881
  const execClientMessage = create(ExecClientMessageSchema, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-oauth-opencode",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "OpenCode plugin that connects Cursor's API to OpenCode via OAuth, model discovery, and a local OpenAI-compatible proxy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs && bun build ./bin/setup.ts --target=node --format=esm --outfile=bin/setup.js --external=jsonc-parser",
28
+ "bench": "bun scripts/bench.ts",
28
29
  "test": "bun test/smoke.ts",
29
30
  "prepack": "npm run build",
30
31
  "prepublishOnly": "npm run build"