@justjoshn/pi-spacex-oauth-v2 0.0.0-stage → 0.1.0
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/README.md +24 -2
- package/package.json +43 -4
- package/src/catalog.ts +20 -0
- package/src/cursor/auth.ts +73 -0
- package/src/cursor/models.ts +101 -0
- package/src/cursor/proto.ts +194 -0
- package/src/cursor/provider.ts +30 -0
- package/src/cursor/run.ts +357 -0
- package/src/cursor/stream.ts +249 -0
- package/src/grok.ts +152 -0
- package/src/index.ts +53 -0
- package/src/usage.ts +63 -0
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-spacex-oauth-v2
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use your X subscription in Pi through Grok Build and Cursor sign-ins. This extension needs Pi 1.5.2 or later.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Install it with:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi install npm:@justjoshn/pi-spacex-oauth-v2
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
To try it from this checkout, use `pi -e extensions/pi-spacex-oauth-v2`.
|
|
14
|
+
|
|
15
|
+
## Sign in and pick a model
|
|
16
|
+
|
|
17
|
+
1. Start Pi and run `/login`.
|
|
18
|
+
2. Choose **Grok Build (X subscription)** or **Cursor (X subscription)** and complete the sign-in prompts.
|
|
19
|
+
3. Run `/model` and pick a `spacex-grok` or `spacex-cursor` model for the account you signed in with.
|
|
20
|
+
|
|
21
|
+
Run `/login` again to sign in to the other provider if you use both. Run `/spacex-usage` to check the signed-in accounts' plan usage and the Grok Build rate limits returned by requests.
|
|
22
|
+
|
|
23
|
+
## Access risks
|
|
24
|
+
|
|
25
|
+
The extension uses undocumented Grok Build and Cursor endpoints and identifies requests as their clients. Vendor changes to those endpoints, client requirements, or account access can break sign-in or requests. Use only accounts you own.
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justjoshn/pi-spacex-oauth-v2",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Use your X/SpaceX subscription in pi through Grok Build and Cursor CLI sign-ins.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/just-joshn/pi-customizations.git",
|
|
13
|
+
"directory": "extensions/pi-spacex-oauth-v2"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package"
|
|
17
|
+
],
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./src/index.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-ai": "*",
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
37
|
+
"test:coverage": "vitest run --coverage"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "1.5.2",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "1.5.2",
|
|
42
|
+
"vitest": "5.1.0",
|
|
43
|
+
"@vitest/coverage-v8": "5.1.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AnyModel, RefreshModelsContext } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fetches the live model list when pi allows network access and persists it, so print mode and
|
|
6
|
+
* offline starts (which refresh without network) still see every model from the last live fetch.
|
|
7
|
+
*/
|
|
8
|
+
export async function refreshCatalog(
|
|
9
|
+
ctx: RefreshModelsContext,
|
|
10
|
+
target: { provider: string; api: string; baseUrl: string },
|
|
11
|
+
fallback: ProviderModelConfig[],
|
|
12
|
+
fetchLive: (accessToken: string, signal: AbortSignal) => Promise<ProviderModelConfig[]>,
|
|
13
|
+
): Promise<ProviderModelConfig[]> {
|
|
14
|
+
const stored = ctx.stored?.models as ProviderModelConfig[] | undefined;
|
|
15
|
+
if (!ctx.allowNetwork || ctx.credential?.type !== 'oauth') return stored ?? fallback;
|
|
16
|
+
const models = await fetchLive(ctx.credential.access, ctx.signal);
|
|
17
|
+
const persisted = models.map((m) => ({ ...target, ...m }) as unknown as AnyModel);
|
|
18
|
+
await ctx.publish({ persist: { models: persisted, checkedAt: Date.now() } });
|
|
19
|
+
return models;
|
|
20
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { OAuthCredentials, OAuthLoginCallbacks } from '@earendil-works/pi-ai';
|
|
4
|
+
|
|
5
|
+
export const CURSOR_API = 'https://api2.cursor.sh';
|
|
6
|
+
const CURSOR_WEBSITE = 'https://cursor.com';
|
|
7
|
+
// The production auth client id the Cursor IDE uses to refresh browser sign-ins.
|
|
8
|
+
const CURSOR_CLIENT_ID = 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB';
|
|
9
|
+
|
|
10
|
+
const base64url = (buf: Buffer) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
11
|
+
|
|
12
|
+
export function jwtExpiry(token: string): number {
|
|
13
|
+
const payload = token.split('.')[1];
|
|
14
|
+
if (!payload) throw new Error('Cursor returned a token that is not a JWT');
|
|
15
|
+
const { exp } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: number };
|
|
16
|
+
if (!exp) throw new Error('Cursor token has no expiry');
|
|
17
|
+
return exp * 1000;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loginUrl(challenge: string, uuid: string): string {
|
|
21
|
+
const url = new URL('/loginDeepControl', CURSOR_WEBSITE);
|
|
22
|
+
url.searchParams.set('challenge', challenge);
|
|
23
|
+
url.searchParams.set('uuid', uuid);
|
|
24
|
+
url.searchParams.set('mode', 'login');
|
|
25
|
+
url.searchParams.set('redirectTarget', 'cli');
|
|
26
|
+
return url.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sleep = (ms: number, signal?: AbortSignal) =>
|
|
30
|
+
new Promise<void>((resolve, reject) => {
|
|
31
|
+
const timer = setTimeout(resolve, ms);
|
|
32
|
+
signal?.addEventListener(
|
|
33
|
+
'abort',
|
|
34
|
+
() => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
reject(signal.reason);
|
|
37
|
+
},
|
|
38
|
+
{ once: true },
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export async function cursorLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
43
|
+
const verifier = base64url(randomBytes(32));
|
|
44
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
45
|
+
const uuid = randomUUID();
|
|
46
|
+
callbacks.onAuth({ url: loginUrl(challenge, uuid), instructions: 'Sign in to Cursor in the browser, then return here.' });
|
|
47
|
+
// Same schedule as cursor-agent: 404 means not approved yet, back off 1s * 1.2^n up to 10s, 150 tries.
|
|
48
|
+
for (let attempt = 0; attempt < 150; attempt++) {
|
|
49
|
+
const res = await fetch(`${CURSOR_API}/auth/poll?uuid=${uuid}&verifier=${verifier}`, { ...(callbacks.signal ? { signal: callbacks.signal } : {}) });
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
const body = (await res.json()) as { accessToken?: string; refreshToken?: string };
|
|
52
|
+
if (!body.accessToken || !body.refreshToken) throw new Error('Cursor sign-in finished without a Cursor access token');
|
|
53
|
+
return { access: body.accessToken, refresh: body.refreshToken, expires: jwtExpiry(body.accessToken) };
|
|
54
|
+
}
|
|
55
|
+
if (res.status !== 404) throw new Error(`Cursor sign-in failed: HTTP ${res.status}`);
|
|
56
|
+
await sleep(Math.min(1000 * 1.2 ** attempt, 10_000), callbacks.signal);
|
|
57
|
+
}
|
|
58
|
+
throw new Error('Cursor sign-in timed out before it was approved');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function cursorRefresh(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials> {
|
|
62
|
+
const res = await fetch(`${CURSOR_API}/oauth/token`, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ grant_type: 'refresh_token', client_id: CURSOR_CLIENT_ID, refresh_token: credentials.refresh }),
|
|
66
|
+
signal,
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok && res.status >= 500) throw new Error(`Cursor token refresh failed: HTTP ${res.status}`);
|
|
69
|
+
const body = (await res.json()) as { access_token?: string; refresh_token?: string; shouldLogout?: boolean; error?: string };
|
|
70
|
+
if (body.shouldLogout) throw new Error(`Cursor ended this sign-in${body.error ? `: ${body.error}` : ''}. Run /login again.`);
|
|
71
|
+
if (!body.access_token) throw new Error(`Cursor token refresh failed: HTTP ${res.status}`);
|
|
72
|
+
return { ...credentials, access: body.access_token, refresh: body.refresh_token ?? credentials.refresh, expires: jwtExpiry(body.access_token) };
|
|
73
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { CURSOR_API } from './auth.ts';
|
|
3
|
+
|
|
4
|
+
export const CURSOR_CLIENT_VERSION = 'cli-2026.11.13-eacade1';
|
|
5
|
+
export const cursorHeaders = (token: string) => ({
|
|
6
|
+
authorization: `Bearer ${token}`,
|
|
7
|
+
'x-cursor-client-type': 'cli',
|
|
8
|
+
'x-cursor-client-version': CURSOR_CLIENT_VERSION,
|
|
9
|
+
'x-ghost-mode': 'true',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
13
|
+
type Effort = (typeof EFFORTS)[number];
|
|
14
|
+
const EFFORT_WORDS = /\s+(None|Minimal|Low|Medium|High|Extra High|Max)(?=\s+Thinking|\s+Fast|\s+\(|$)/;
|
|
15
|
+
|
|
16
|
+
export interface CursorModelDetails {
|
|
17
|
+
modelId: string;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Variant {
|
|
22
|
+
id: string;
|
|
23
|
+
effort?: Effort;
|
|
24
|
+
displayName: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Splits a flat Cursor id like `claude-opus-5-5-high-fast` into its pi model id and effort. */
|
|
28
|
+
export function parseVariant(id: string): { piId: string; effort?: Effort } {
|
|
29
|
+
const fast = id.endsWith('-fast');
|
|
30
|
+
const stem = fast ? id.slice(0, -5) : id;
|
|
31
|
+
const effort = EFFORTS.find((e) => stem.endsWith(`-${e}`));
|
|
32
|
+
const base = effort ? stem.slice(0, -(effort.length + 1)) : stem;
|
|
33
|
+
return { piId: fast ? `${base}-fast` : base, ...(effort ? { effort } : {}) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cleanName = (name: string) => name.replace(/[-]/g, '').replace(/\s+/g, ' ').trim();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collapses Cursor's per-effort ids into one pi model per base id (and one per `-fast` base).
|
|
40
|
+
* The pi thinking level picks the variant through thinkingLevelMap, whose values are real Cursor ids.
|
|
41
|
+
*/
|
|
42
|
+
export function collapseModels(models: CursorModelDetails[]): ProviderModelConfig[] {
|
|
43
|
+
const groups = new Map<string, Variant[]>();
|
|
44
|
+
for (const m of models) {
|
|
45
|
+
if (m.modelId === 'default') continue;
|
|
46
|
+
const { piId, effort } = parseVariant(m.modelId);
|
|
47
|
+
groups.set(piId, [...(groups.get(piId) ?? []), { id: m.modelId, ...(effort ? { effort } : {}), displayName: cleanName(m.displayName ?? m.modelId) }]);
|
|
48
|
+
}
|
|
49
|
+
const out: ProviderModelConfig[] = [{ id: 'auto', name: 'Auto', reasoning: false, input: ['text'], cost: zeroCost, contextWindow: 200_000, maxTokens: 64_000 }];
|
|
50
|
+
for (const [piId, variants] of groups) {
|
|
51
|
+
const plain = variants.find((v) => !v.effort);
|
|
52
|
+
const byEffort = new Map(variants.filter((v) => v.effort).map((v) => [v.effort as Effort, v.id]));
|
|
53
|
+
const named = plain ?? variants.find((v) => v.effort === 'high') ?? variants[0];
|
|
54
|
+
const name = (named?.displayName ?? piId).replace(EFFORT_WORDS, '');
|
|
55
|
+
out.push({
|
|
56
|
+
id: piId,
|
|
57
|
+
name,
|
|
58
|
+
reasoning: byEffort.size > 0,
|
|
59
|
+
...(byEffort.size > 0
|
|
60
|
+
? {
|
|
61
|
+
thinkingLevelMap: {
|
|
62
|
+
off: plain?.id ?? byEffort.get('none') ?? null,
|
|
63
|
+
minimal: byEffort.get('minimal') ?? null,
|
|
64
|
+
low: byEffort.get('low') ?? null,
|
|
65
|
+
medium: byEffort.get('medium') ?? null,
|
|
66
|
+
high: byEffort.get('high') ?? null,
|
|
67
|
+
xhigh: byEffort.get('xhigh') ?? null,
|
|
68
|
+
max: byEffort.get('max') ?? null,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
: {}),
|
|
72
|
+
input: ['text'],
|
|
73
|
+
cost: zeroCost,
|
|
74
|
+
contextWindow: /\b1M\b/.test(name) ? 1_000_000 : 200_000,
|
|
75
|
+
maxTokens: 64_000,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
82
|
+
|
|
83
|
+
/** The Cursor id to request for a pi model at a pi thinking level. */
|
|
84
|
+
export function resolveCursorModelId(piId: string, map: Partial<Record<string, string | null>> | undefined, level: string | undefined): string {
|
|
85
|
+
if (piId === 'auto') return 'default';
|
|
86
|
+
if (!map) return piId;
|
|
87
|
+
const pick = (l: string) => map[l] ?? undefined;
|
|
88
|
+
return pick(level ?? 'off') ?? pick('medium') ?? pick('high') ?? pick('low') ?? Object.values(map).find((v): v is string => !!v) ?? piId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function fetchCursorModels(token: string, signal?: AbortSignal): Promise<ProviderModelConfig[]> {
|
|
92
|
+
const res = await fetch(`${CURSOR_API}/agent.v1.AgentService/GetUsableModels`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { ...cursorHeaders(token), 'content-type': 'application/json', 'connect-protocol-version': '1' },
|
|
95
|
+
body: '{}',
|
|
96
|
+
...(signal ? { signal } : {}),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) throw new Error(`Cursor model list failed: HTTP ${res.status}`);
|
|
99
|
+
const body = (await res.json()) as { models?: CursorModelDetails[] };
|
|
100
|
+
return collapseModels(body.models ?? []);
|
|
101
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Minimal protobuf wire codec and Connect envelope framing for the agent.v1 messages the bridge uses.
|
|
2
|
+
// Field numbers come from the schema embedded in cursor-agent (scripts/extract-cursor-schema.mjs).
|
|
3
|
+
import { gunzipSync } from 'node:zlib';
|
|
4
|
+
|
|
5
|
+
/** A message is a list of [field number, value]; nested messages are Field[] themselves. */
|
|
6
|
+
export type Field = [number, FieldValue];
|
|
7
|
+
export type FieldValue = string | number | boolean | Uint8Array | Field[] | undefined;
|
|
8
|
+
|
|
9
|
+
function varint(n: number | bigint, out: number[]) {
|
|
10
|
+
let v = BigInt(n);
|
|
11
|
+
if (v < 0n) v += 1n << 64n;
|
|
12
|
+
while (v > 0x7fn) {
|
|
13
|
+
out.push(Number(v & 0x7fn) | 0x80);
|
|
14
|
+
v >>= 7n;
|
|
15
|
+
}
|
|
16
|
+
out.push(Number(v));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function encode(fields: Field[]): Uint8Array {
|
|
20
|
+
const out: number[] = [];
|
|
21
|
+
for (const [no, value] of fields) {
|
|
22
|
+
if (value === undefined) continue;
|
|
23
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
24
|
+
varint((no << 3) | 0, out);
|
|
25
|
+
varint(typeof value === 'boolean' ? Number(value) : value, out);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value instanceof Uint8Array ? value : encode(value);
|
|
29
|
+
varint((no << 3) | 2, out);
|
|
30
|
+
varint(bytes.length, out);
|
|
31
|
+
for (const b of bytes) out.push(b);
|
|
32
|
+
}
|
|
33
|
+
return Uint8Array.from(out);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Doubles need wire type 1, which `encode` never infers; used only for google.protobuf.Value numbers. */
|
|
37
|
+
function double(no: number, n: number): Uint8Array {
|
|
38
|
+
const buf = new Uint8Array(9);
|
|
39
|
+
buf[0] = (no << 3) | 1;
|
|
40
|
+
new DataView(buf.buffer).setFloat64(1, n, true);
|
|
41
|
+
return buf;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Decoded {
|
|
45
|
+
varints: Map<number, bigint[]>;
|
|
46
|
+
bytes: Map<number, Uint8Array[]>;
|
|
47
|
+
fixed64: Map<number, Uint8Array[]>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function decode(buf: Uint8Array): Decoded {
|
|
51
|
+
const d: Decoded = { varints: new Map(), bytes: new Map(), fixed64: new Map() };
|
|
52
|
+
let pos = 0;
|
|
53
|
+
const readVarint = () => {
|
|
54
|
+
let shift = 0n;
|
|
55
|
+
let result = 0n;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const b = buf[pos++];
|
|
58
|
+
if (b === undefined) throw new Error('truncated protobuf varint');
|
|
59
|
+
result |= BigInt(b & 0x7f) << shift;
|
|
60
|
+
if ((b & 0x80) === 0) return result;
|
|
61
|
+
shift += 7n;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const push = <T>(map: Map<number, T[]>, no: number, v: T) => map.set(no, [...(map.get(no) ?? []), v]);
|
|
65
|
+
while (pos < buf.length) {
|
|
66
|
+
const tag = Number(readVarint());
|
|
67
|
+
const no = tag >>> 3;
|
|
68
|
+
switch (tag & 7) {
|
|
69
|
+
case 0:
|
|
70
|
+
push(d.varints, no, readVarint());
|
|
71
|
+
break;
|
|
72
|
+
case 1:
|
|
73
|
+
if (pos + 8 > buf.length) throw new Error('truncated protobuf field');
|
|
74
|
+
push(d.fixed64, no, buf.subarray(pos, pos + 8));
|
|
75
|
+
pos += 8;
|
|
76
|
+
break;
|
|
77
|
+
case 2: {
|
|
78
|
+
const len = Number(readVarint());
|
|
79
|
+
if (pos + len > buf.length) throw new Error('truncated protobuf field');
|
|
80
|
+
push(d.bytes, no, buf.subarray(pos, pos + len));
|
|
81
|
+
pos += len;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case 5:
|
|
85
|
+
if (pos + 4 > buf.length) throw new Error('truncated protobuf field');
|
|
86
|
+
pos += 4;
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`unsupported protobuf wire type ${tag & 7}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return d;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const str = (d: Decoded, no: number) => {
|
|
96
|
+
const b = d.bytes.get(no)?.[0];
|
|
97
|
+
return b ? new TextDecoder().decode(b) : '';
|
|
98
|
+
};
|
|
99
|
+
export const msg = (d: Decoded, no: number) => {
|
|
100
|
+
const b = d.bytes.get(no)?.[0];
|
|
101
|
+
return b ? decode(b) : undefined;
|
|
102
|
+
};
|
|
103
|
+
export const num = (d: Decoded, no: number) => Number(d.varints.get(no)?.[0] ?? 0n);
|
|
104
|
+
export const raw = (d: Decoded, no: number) => d.bytes.get(no)?.[0];
|
|
105
|
+
|
|
106
|
+
// google.protobuf.Value, used for MCP tool schemas and arguments.
|
|
107
|
+
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
108
|
+
|
|
109
|
+
export function encodeValue(v: Json): Uint8Array {
|
|
110
|
+
if (v === null) return encode([[1, 0]]);
|
|
111
|
+
if (typeof v === 'number') return double(2, v);
|
|
112
|
+
if (typeof v === 'string') return encode([[3, v]]);
|
|
113
|
+
if (typeof v === 'boolean') return encode([[4, v]]);
|
|
114
|
+
if (Array.isArray(v)) return encode([[6, v.map((x): Field => [1, encodeValue(x)])]]);
|
|
115
|
+
return encode([
|
|
116
|
+
[
|
|
117
|
+
5,
|
|
118
|
+
Object.entries(v).map(
|
|
119
|
+
([k, x]): Field => [
|
|
120
|
+
1,
|
|
121
|
+
[
|
|
122
|
+
[1, k],
|
|
123
|
+
[2, encodeValue(x)],
|
|
124
|
+
],
|
|
125
|
+
],
|
|
126
|
+
),
|
|
127
|
+
],
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function decodeValue(buf: Uint8Array): Json {
|
|
132
|
+
const d = decode(buf);
|
|
133
|
+
const f64 = d.fixed64.get(2)?.[0];
|
|
134
|
+
if (f64) return new DataView(f64.buffer, f64.byteOffset, 8).getFloat64(0, true);
|
|
135
|
+
if (d.bytes.has(3)) return str(d, 3);
|
|
136
|
+
if (d.varints.has(4)) return num(d, 4) !== 0;
|
|
137
|
+
const struct = raw(d, 5);
|
|
138
|
+
if (struct) return decodeStruct(struct);
|
|
139
|
+
const list = raw(d, 6);
|
|
140
|
+
if (list) return (decode(list).bytes.get(1) ?? []).map(decodeValue);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function decodeStruct(buf: Uint8Array): { [key: string]: Json } {
|
|
145
|
+
const out: { [key: string]: Json } = {};
|
|
146
|
+
for (const entry of decode(buf).bytes.get(1) ?? []) {
|
|
147
|
+
const e = decode(entry);
|
|
148
|
+
const value = raw(e, 2);
|
|
149
|
+
out[str(e, 1)] = value ? decodeValue(value) : null;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Decodes a protobuf map<string, google.protobuf.Value> field into a plain object. */
|
|
155
|
+
export function decodeValueMap(d: Decoded, no: number): { [key: string]: Json } {
|
|
156
|
+
const out: { [key: string]: Json } = {};
|
|
157
|
+
for (const entry of d.bytes.get(no) ?? []) {
|
|
158
|
+
const e = decode(entry);
|
|
159
|
+
const value = raw(e, 2);
|
|
160
|
+
out[str(e, 1)] = value ? decodeValue(value) : null;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Connect streaming envelopes: 1 flag byte (bit 0 compressed, bit 1 end-stream) + 4-byte big-endian length.
|
|
166
|
+
export function frame(payload: Uint8Array): Uint8Array {
|
|
167
|
+
const out = new Uint8Array(5 + payload.length);
|
|
168
|
+
new DataView(out.buffer).setUint32(1, payload.length);
|
|
169
|
+
out.set(payload, 5);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type Envelope = { end: false; payload: Uint8Array } | { end: true; json: string };
|
|
174
|
+
|
|
175
|
+
export class EnvelopeReader {
|
|
176
|
+
private buf = new Uint8Array(0);
|
|
177
|
+
push(chunk: Uint8Array): Envelope[] {
|
|
178
|
+
const merged = new Uint8Array(this.buf.length + chunk.length);
|
|
179
|
+
merged.set(this.buf);
|
|
180
|
+
merged.set(chunk, this.buf.length);
|
|
181
|
+
this.buf = merged;
|
|
182
|
+
const out: Envelope[] = [];
|
|
183
|
+
while (this.buf.length >= 5) {
|
|
184
|
+
const flags = this.buf[0] ?? 0;
|
|
185
|
+
const len = new DataView(this.buf.buffer, this.buf.byteOffset).getUint32(1);
|
|
186
|
+
if (this.buf.length < 5 + len) break;
|
|
187
|
+
let payload: Uint8Array = this.buf.slice(5, 5 + len);
|
|
188
|
+
this.buf = this.buf.slice(5 + len);
|
|
189
|
+
if (flags & 1) payload = gunzipSync(payload);
|
|
190
|
+
out.push(flags & 2 ? { end: true, json: new TextDecoder().decode(payload) } : { end: false, payload });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ProviderConfig } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { refreshCatalog } from '../catalog.ts';
|
|
3
|
+
import { cursorLogin, cursorRefresh } from './auth.ts';
|
|
4
|
+
import { collapseModels, fetchCursorModels } from './models.ts';
|
|
5
|
+
import { AGENT_URL } from './run.ts';
|
|
6
|
+
import { streamCursor } from './stream.ts';
|
|
7
|
+
|
|
8
|
+
export const CURSOR_PROVIDER_ID = 'spacex-cursor';
|
|
9
|
+
|
|
10
|
+
// Composer and Auto until the first live refresh replaces the list with GetUsableModels.
|
|
11
|
+
const FALLBACK_MODELS = collapseModels([
|
|
12
|
+
{ modelId: 'composer-2.5', displayName: 'Composer 2.5' },
|
|
13
|
+
{ modelId: 'composer-2.5-fast', displayName: 'Composer 2.5 Fast' },
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const cursorProvider: ProviderConfig = {
|
|
17
|
+
name: 'Cursor (X subscription)',
|
|
18
|
+
baseUrl: AGENT_URL,
|
|
19
|
+
api: 'cursor-agent',
|
|
20
|
+
streamSimple: streamCursor,
|
|
21
|
+
models: FALLBACK_MODELS,
|
|
22
|
+
refreshModels: (ctx) => refreshCatalog(ctx, { provider: CURSOR_PROVIDER_ID, api: 'cursor-agent', baseUrl: AGENT_URL }, FALLBACK_MODELS, fetchCursorModels),
|
|
23
|
+
oauth: {
|
|
24
|
+
name: 'Cursor (X subscription)',
|
|
25
|
+
isSubscription: true,
|
|
26
|
+
login: cursorLogin,
|
|
27
|
+
refreshToken: cursorRefresh,
|
|
28
|
+
getApiKey: (credentials) => credentials.access,
|
|
29
|
+
},
|
|
30
|
+
};
|