Skip to content

Commit a66c39f

Browse files
alex-pexcursoragent
andcommitted
feat(cursor): add an optional Cursor team id
A Cursor account can belong to several teams. T3 Code sent no team, so runs always billed the account's default team, with no way to choose another (#16840). @cursor/sdk 1.2.0 accepts a team id. Bump it and add an optional `CURSOR_TEAM_ID` provider setting that applies to browser sign-in and to `CURSOR_API_KEY`. Agent runs, text generation, and the model catalog use the team, and the provider status publishes its id so usage limits group by team. Without the setting, nothing changes. Validation: `vp run check:changed` passes, with tests for `CURSOR_TEAM_ID` parsing and scoping, the catalog team header and per-team cache, and the status. Model: Claude Opus 5.5. Harness: Cursor. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 47f2c8d commit a66c39f

14 files changed

Lines changed: 227 additions & 101 deletions

File tree

‎apps/server/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
},
3535
"dependencies": {
3636
"@anthropic-ai/claude-agent-sdk": "^0.3.276",
37-
"@cursor/sdk": "1.0.38",
37+
"@cursor/sdk": "1.2.0",
3838
"@effect/platform-node": "catalog:",
3939
"@effect/platform-node-shared": "catalog:",
4040
"@ff-labs/fff-node": "0.11.0",

‎docs/user/cursor.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ Choose **Sign in**, then open the sign-in page and complete it in your browser.
2121
updates automatically when sign-in finishes. This works when connected to a remote environment too.
2222
On mobile, use **Settings > Providers** for an already configured Cursor instance.
2323

24+
If your Cursor account belongs to several teams, runs use the account's default team. To use another
25+
team, enter its id in **Team ID**. It applies to browser sign-in and to `CURSOR_API_KEY`.
26+
2427
Each provider instance keeps its own login on the environment that runs it. Your Cursor editor and
2528
CLI login are separate. A configured `CURSOR_API_KEY` overrides browser sign-in; remove that override
2629
to use the browser flow.

‎packages/provider-cursor/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"test": "vp test run --config ../../vite.config.ts --dir ."
5050
},
5151
"dependencies": {
52-
"@cursor/sdk": "1.0.38",
52+
"@cursor/sdk": "1.2.0",
5353
"@napi-rs/keyring": "^1.3.0",
5454
"@t3tools/contracts": "workspace:*",
5555
"@t3tools/provider-core": "workspace:*",

‎packages/provider-cursor/src/client.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export const cursorClient = defineProviderClient({
1717
placeholder: "Paste API key",
1818
sensitive: true,
1919
},
20+
{
21+
name: "CURSOR_TEAM_ID",
22+
label: "Team ID",
23+
description: "Optional. Leave empty for the account's default team.",
24+
sensitive: false,
25+
},
2026
],
2127
icon: {
2228
viewBox: "0 0 466.73 532.09",

‎packages/provider-cursor/src/server/CursorSdkCatalog.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ describe("CursorSdkCatalog", () => {
8282
];
8383
const client = HttpClient.make((request) => {
8484
expect(request.headers.authorization).toBe("Bearer test-key");
85+
expect(request.headers["x-cursor-team-id"]).toBe("42");
8586
expect(request.method).toBe("GET");
8687
requests.push(request.url);
8788
const matches = request.url === `https://api.cursor.com/v1/${route}`;
@@ -104,7 +105,7 @@ describe("CursorSdkCatalog", () => {
104105
),
105106
);
106107
expect(sdkLoad).not.toHaveBeenCalled();
107-
const result = yield* catalog.read("test-key").pipe(Effect.exit);
108+
const result = yield* catalog.read("test-key", 42).pipe(Effect.exit);
108109
expect(requests).toContain(`https://api.cursor.com/v1/${route}`);
109110
if (expected === "ready" || expected === "no-user") {
110111
expect(Exit.isSuccess(result)).toBe(true);
@@ -159,7 +160,7 @@ describe("CursorSdkCatalog", () => {
159160
}),
160161
);
161162

162-
it.effect("does not cache failed or empty model discovery", () =>
163+
it.effect("does not cache failed or empty model discovery, nor across teams", () =>
163164
Effect.gen(function* () {
164165
let modelCalls = 0;
165166
const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
@@ -178,6 +179,8 @@ describe("CursorSdkCatalog", () => {
178179
expect((yield* catalog.read("test-key")).models).toEqual([model]);
179180
expect((yield* catalog.read("test-key")).models).toEqual([model]);
180181
expect(modelCalls).toBe(3);
182+
yield* catalog.read("test-key", 42);
183+
expect(modelCalls).toBe(4);
181184
}),
182185
);
183186
});

‎packages/provider-cursor/src/server/CursorSdkCatalog.ts‎

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as HttpClient from "effect/http/HttpClient";
66
import * as HttpClientRequest from "effect/http/HttpClientRequest";
77
import * as HttpClientResponse from "effect/http/HttpClientResponse";
88
import * as Context from "effect/Context";
9+
import * as Data from "effect/Data";
910
import * as Duration from "effect/Duration";
1011
import * as Effect from "effect/Effect";
1112
import * as Exit from "effect/Exit";
@@ -46,14 +47,19 @@ export class CursorSdkCatalog extends Context.Service<
4647
{
4748
readonly read: (
4849
apiKey: string,
50+
teamId?: number,
4951
) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;
5052
}
5153
>()("@t3tools/provider-cursor/server/CursorSdkCatalog") {}
5254

5355
export interface CursorSdkCatalogProbes {
54-
readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>;
56+
readonly readUser: (
57+
apiKey: string,
58+
teamId?: number,
59+
) => Effect.Effect<SDKUser, CursorSdkCatalogError>;
5560
readonly readModels: (
5661
apiKey: string,
62+
teamId?: number,
5763
) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>;
5864
}
5965

@@ -111,10 +117,17 @@ const makeLiveProbes = Effect.gen(function* () {
111117
Config.withDefault("https://api.cursor.com"),
112118
Effect.orDie,
113119
)).replace(/\/$/, "");
114-
const request = Effect.fn("CursorSdkCatalog.request")(function* (apiKey: string, path: string) {
120+
const request = Effect.fn("CursorSdkCatalog.request")(function* (
121+
apiKey: string,
122+
path: string,
123+
teamId: number | undefined,
124+
) {
115125
const response = yield* client
116126
.execute(
117-
HttpClientRequest.get(`${baseUrl}${path}`).pipe(HttpClientRequest.bearerToken(apiKey)),
127+
HttpClientRequest.get(`${baseUrl}${path}`).pipe(
128+
HttpClientRequest.bearerToken(apiKey),
129+
HttpClientRequest.setHeaders(teamId ? { "x-cursor-team-id": String(teamId) } : {}),
130+
),
118131
)
119132
.pipe(
120133
Effect.mapError(
@@ -136,17 +149,17 @@ const makeLiveProbes = Effect.gen(function* () {
136149
return response;
137150
});
138151
return {
139-
readUser: (apiKey) =>
140-
request(apiKey, "/v1/me").pipe(
152+
readUser: (apiKey, teamId) =>
153+
request(apiKey, "/v1/me", teamId).pipe(
141154
Effect.flatMap(HttpClientResponse.schemaBodyJson(CatalogUser)),
142155
Effect.mapError((cause) =>
143156
isCatalogError(cause)
144157
? cause
145158
: new CursorSdkCatalogError({ authenticationFailure: false, cause }),
146159
),
147160
),
148-
readModels: (apiKey) =>
149-
request(apiKey, "/v1/models").pipe(
161+
readModels: (apiKey, teamId) =>
162+
request(apiKey, "/v1/models", teamId).pipe(
150163
Effect.flatMap(HttpClientResponse.schemaBodyJson(CatalogModels)),
151164
Effect.map((body) => body.items),
152165
Effect.mapError((cause) =>
@@ -158,26 +171,34 @@ const makeLiveProbes = Effect.gen(function* () {
158171
} satisfies CursorSdkCatalogProbes;
159172
});
160173

174+
class ModelCacheKey extends Data.Class<{
175+
readonly apiKey: string;
176+
readonly teamId: number | undefined;
177+
}> {}
178+
161179
export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* (
162180
probes: CursorSdkCatalogProbes,
163181
) {
164-
const modelCache = yield* Cache.makeWith(probes.readModels, {
165-
capacity: 32,
166-
timeToLive: (exit) =>
167-
Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
168-
});
182+
const modelCache = yield* Cache.makeWith(
183+
(key: ModelCacheKey) => probes.readModels(key.apiKey, key.teamId),
184+
{
185+
capacity: 32,
186+
timeToLive: (exit) =>
187+
Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
188+
},
189+
);
169190
return CursorSdkCatalog.of({
170-
read: Effect.fn("CursorSdkCatalog.read")(function (apiKey) {
191+
read: Effect.fn("CursorSdkCatalog.read")(function (apiKey, teamId) {
171192
return Effect.all(
172193
{
173194
// Enterprise teams can block account labels while model discovery still works.
174195
user: Effect.catchIf(
175-
probes.readUser(apiKey),
196+
probes.readUser(apiKey, teamId),
176197
(error: CursorSdkCatalogError): boolean => error.accountBlocked,
177198
() => Effect.undefined,
178199
(error: CursorSdkCatalogError) => Effect.fail(error),
179200
),
180-
models: Cache.get(modelCache, apiKey),
201+
models: Cache.get(modelCache, new ModelCacheKey({ apiKey, teamId })),
181202
},
182203
{ concurrency: "unbounded" },
183204
);

‎packages/provider-cursor/src/server/auth.test.ts‎

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as Option from "effect/Option";
1111
import * as Stream from "effect/Stream";
1212

1313
import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts";
14+
import type { CursorTeam } from "./auth.ts";
1415
import { loadCursorSdk } from "./sdk.ts";
1516

1617
const { InMemoryCredentialStore } = loadCursorSdk();
@@ -72,6 +73,8 @@ const makeHarness = Effect.fn("makeCursorAuthHarness")(function* (
7273
});
7374

7475
type Auth = Effect.Success<ReturnType<typeof makeCursorAuth>>;
76+
const readApiKey = (auth: Auth) => Effect.map(auth.readAccess, (access) => access?.apiKey);
77+
const requireApiKey = (auth: Auth) => Effect.map(auth.requireAccess, (access) => access.apiKey);
7578
const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) =>
7679
auth.controller.subscribe(session).pipe(
7780
Stream.filter((state) => state.phase === phase),
@@ -112,15 +115,15 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
112115
expect(closedSession).toBe(true);
113116
expect(closedRequest).toBe(true);
114117
expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true);
115-
expect(yield* harness.auth.readApiKey).toBeUndefined();
118+
expect(yield* readApiKey(harness.auth)).toBeUndefined();
116119
}).pipe(Effect.scoped),
117120
);
118121

119122
it.effect("completes a remote browser login, saves its key and refreshes provider status", () =>
120123
Effect.gen(function* () {
121124
const harness = yield* makeHarness();
122125
let stopped = false;
123-
expect(yield* harness.auth.readApiKey).toBeUndefined();
126+
expect(yield* readApiKey(harness.auth)).toBeUndefined();
124127
const starting = yield* harness.auth.controller.start(
125128
owner,
126129
Effect.sync(() => {
@@ -133,11 +136,11 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
133136
expect(waiting.flowId).toBe(starting.flowId);
134137
expect(harness.loginOptions()?.openBrowser).toBe(false);
135138
expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId);
136-
expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
139+
expect(Exit.isFailure(yield* Effect.exit(requireApiKey(harness.auth)))).toBe(true);
137140
yield* Effect.sync(() => harness.complete.resolve());
138141
const succeeded = yield* phase(harness.auth, "succeeded");
139142
expect(succeeded.authorizationUrl).toBeNull();
140-
expect(yield* harness.auth.requireApiKey).toBe("browser-key");
143+
expect(yield* requireApiKey(harness.auth)).toBe("browser-key");
141144
expect(harness.changes).toEqual([true]);
142145
expect(Object.values(succeeded)).not.toContain("browser-key");
143146
expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials);
@@ -180,7 +183,7 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
180183
expect(harness.loginOptions()?.signal?.aborted).toBe(true);
181184
yield* Effect.sync(() => harness.complete.resolve());
182185
yield* Effect.promise(() => harness.returned.promise);
183-
expect(yield* harness.auth.readApiKey).toBeUndefined();
186+
expect(yield* readApiKey(harness.auth)).toBeUndefined();
184187
expect(harness.changes).toEqual([]);
185188
}).pipe(Effect.scoped),
186189
);
@@ -208,12 +211,12 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
208211
const failed = yield* phase(harness.auth, "failed");
209212
expect(failed.message).toBe(rejectionMessage);
210213
expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined();
211-
expect(yield* harness.auth.readApiKey).toBeUndefined();
212-
expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
214+
expect(yield* readApiKey(harness.auth)).toBeUndefined();
215+
expect(Exit.isFailure(yield* Effect.exit(requireApiKey(harness.auth)))).toBe(true);
213216
rejected = false;
214217
yield* harness.auth.controller.start(owner);
215218
yield* phase(harness.auth, "succeeded");
216-
expect(yield* harness.auth.requireApiKey).toBe("browser-key");
219+
expect(yield* requireApiKey(harness.auth)).toBe("browser-key");
217220
}).pipe(Effect.scoped),
218221
);
219222

@@ -225,13 +228,13 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
225228
yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" }));
226229
const state = yield* harness.auth.controller.logout(
227230
Effect.gen(function* () {
228-
expect(yield* harness.auth.readApiKey).toBe("browser-key");
229-
expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
231+
expect(yield* readApiKey(harness.auth)).toBe("browser-key");
232+
expect(Exit.isFailure(yield* Effect.exit(requireApiKey(harness.auth)))).toBe(true);
230233
}),
231234
);
232235
expect(state.message).toBe("Signed out of Cursor.");
233-
expect(yield* harness.auth.readApiKey).toBeUndefined();
234-
expect(yield* other.auth.requireApiKey).toBe("other-key");
236+
expect(yield* readApiKey(harness.auth)).toBeUndefined();
237+
expect(yield* requireApiKey(other.auth)).toBe("other-key");
235238
expect(harness.changes).toEqual([false]);
236239
}).pipe(Effect.scoped),
237240
);
@@ -245,7 +248,7 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
245248
expect(harness.loginOptions()?.signal?.aborted).toBe(true);
246249
yield* Effect.sync(() => harness.complete.resolve());
247250
yield* Effect.promise(() => harness.returned.promise);
248-
expect(yield* harness.auth.readApiKey).toBeUndefined();
251+
expect(yield* readApiKey(harness.auth)).toBeUndefined();
249252
}).pipe(Effect.scoped),
250253
);
251254

@@ -265,7 +268,7 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
265268
),
266269
);
267270
expect(Exit.isFailure(result)).toBe(true);
268-
expect(yield* harness.auth.requireApiKey).toBe("browser-key");
271+
expect(yield* requireApiKey(harness.auth)).toBe("browser-key");
269272
}).pipe(Effect.scoped),
270273
);
271274

@@ -275,7 +278,7 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
275278
Effect.gen(function* () {
276279
const harness = yield* makeHarness({ apiKey: " explicit-key " });
277280
yield* Effect.promise(() => harness.store.save(harness.credentials));
278-
expect(yield* harness.auth.requireApiKey).toBe("explicit-key");
281+
expect(yield* requireApiKey(harness.auth)).toBe("explicit-key");
279282
expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
280283
expect(
281284
Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))),
@@ -285,13 +288,42 @@ it.layer(NodeServices.layer)("CursorAuth", (it) => {
285288
}).pipe(Effect.scoped),
286289
);
287290

291+
it.effect.each<{ name: string; apiKey?: string; teamId?: string; expected?: CursorTeam }>([
292+
{ name: "the default team for browser sign-in" },
293+
{
294+
name: "CURSOR_TEAM_ID for browser sign-in",
295+
teamId: " 42 ",
296+
expected: { id: 42, name: undefined },
297+
},
298+
...["abc", "0", "-1", "1.5", "1e3"].map((teamId) => ({
299+
name: `the default team for an invalid CURSOR_TEAM_ID "${teamId}"`,
300+
teamId,
301+
})),
302+
{ name: "the default team for an explicit API key", apiKey: "explicit-key" },
303+
{
304+
name: "CURSOR_TEAM_ID for an explicit API key",
305+
apiKey: "explicit-key",
306+
teamId: "42",
307+
expected: { id: 42, name: undefined },
308+
},
309+
])("uses $name", ({ apiKey, teamId, expected }) =>
310+
Effect.gen(function* () {
311+
const harness = yield* makeHarness({
312+
...(apiKey ? { apiKey } : {}),
313+
...(teamId ? { teamId } : {}),
314+
});
315+
yield* Effect.promise(() => harness.store.save(harness.credentials));
316+
expect((yield* harness.auth.requireAccess).team).toEqual(expected);
317+
}).pipe(Effect.scoped),
318+
);
319+
288320
it.effect("ignores expired browser credentials and rejects disabled sign-in", () =>
289321
Effect.gen(function* () {
290322
const harness = yield* makeHarness({ enabled: false });
291323
yield* Effect.promise(() =>
292324
harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }),
293325
);
294-
expect(yield* harness.auth.readApiKey).toBeUndefined();
326+
expect(yield* readApiKey(harness.auth)).toBeUndefined();
295327
expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
296328
}).pipe(Effect.scoped),
297329
);

0 commit comments

Comments
 (0)