cursor-oauth-opencode 0.1.1

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 ADDED
@@ -0,0 +1,126 @@
1
+ # cursor-oauth-opencode
2
+
3
+ OpenCode plugin that connects to Cursor's API, giving you access to Cursor
4
+ models inside OpenCode with full tool-calling support.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ npx cursor-oauth-opencode setup --global
10
+ opencode auth login --provider cursor
11
+ ```
12
+
13
+ For project-local setup:
14
+
15
+ ```sh
16
+ npx cursor-oauth-opencode setup --project
17
+ opencode auth login --provider cursor
18
+ ```
19
+
20
+ The setup command is idempotent. It adds the npm plugin entry and a fallback
21
+ `provider.cursor` model registry to `opencode.json`, preserving existing user
22
+ model overrides.
23
+
24
+ ## Manual config
25
+
26
+ If you do not want to use the setup command, add this to
27
+ `~/.config/opencode/opencode.json`:
28
+
29
+ ```jsonc
30
+ {
31
+ "$schema": "https://opencode.ai/config.json",
32
+ "plugin": [
33
+ "cursor-oauth-opencode@latest"
34
+ ],
35
+ "provider": {
36
+ "cursor": {
37
+ "name": "Cursor",
38
+ "npm": "@ai-sdk/openai-compatible",
39
+ "api": "http://127.0.0.1:65535/v1",
40
+ "models": {
41
+ "composer-2-fast": {
42
+ "name": "Composer 2 Fast",
43
+ "reasoning": true,
44
+ "temperature": true,
45
+ "attachment": false,
46
+ "tool_call": true,
47
+ "limit": {
48
+ "context": 200000,
49
+ "output": 64000
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ The fallback API URL is only a placeholder so OpenCode can list the provider
59
+ before login. After auth, the plugin starts a local proxy and replaces Cursor
60
+ models with live Cursor model discovery.
61
+
62
+ ## Authenticate
63
+
64
+ ```sh
65
+ opencode auth login --provider cursor
66
+ ```
67
+
68
+ This opens Cursor OAuth in the browser. Tokens are stored in
69
+ `~/.local/share/opencode/auth.json` and refreshed automatically.
70
+
71
+ ## Use
72
+
73
+ Start OpenCode and select any Cursor model. The plugin starts a local
74
+ OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
75
+
76
+ ## How it works
77
+
78
+ 1. OAuth — browser-based login to Cursor via PKCE.
79
+ 2. Model discovery — queries Cursor's gRPC API for all available models.
80
+ 3. Local proxy — translates `POST /v1/chat/completions` into Cursor's
81
+ protobuf/HTTP/2 Connect protocol.
82
+ 4. Native tool routing — rejects Cursor's built-in filesystem/shell tools and
83
+ exposes OpenCode's tool surface via Cursor MCP instead.
84
+
85
+ HTTP/2 transport runs through a Node child process (`h2-bridge.mjs`) because
86
+ Bun's `node:http2` support is not reliable against Cursor's API.
87
+
88
+ ## Architecture
89
+
90
+ ```
91
+ OpenCode --> /v1/chat/completions --> Bun.serve (proxy)
92
+ |
93
+ Node child process (h2-bridge.mjs)
94
+ |
95
+ HTTP/2 Connect stream
96
+ |
97
+ api2.cursor.sh gRPC
98
+ /agent.v1.AgentService/Run
99
+ ```
100
+
101
+ ### Tool call flow
102
+
103
+ ```
104
+ 1. Cursor model receives OpenAI tools via RequestContext (as MCP tool defs)
105
+ 2. Model tries native tools (readArgs, shellArgs, etc.)
106
+ 3. Proxy rejects each with typed error (ReadRejected, ShellRejected, etc.)
107
+ 4. Model falls back to MCP tool -> mcpArgs exec message
108
+ 5. Proxy emits OpenAI tool_calls SSE chunk, pauses H2 stream
109
+ 6. OpenCode executes tool, sends result in follow-up request
110
+ 7. Proxy resumes H2 stream with mcpResult, streams continuation
111
+ ```
112
+
113
+ ## Develop locally
114
+
115
+ ```sh
116
+ bun install
117
+ bun run build
118
+ bun test/smoke.ts
119
+ ```
120
+
121
+ ## Requirements
122
+
123
+ - [OpenCode](https://opencode.ai)
124
+ - [Bun](https://bun.sh)
125
+ - [Node.js](https://nodejs.org) >= 18 for the HTTP/2 bridge process
126
+ - Active [Cursor](https://cursor.com) subscription
package/bin/setup.js ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/setup.ts
4
+ import { realpathSync } from "node:fs";
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { applyEdits, format, modify, parse } from "jsonc-parser";
10
+ var DEFAULT_PLUGIN_SPEC = "cursor-oauth-opencode@latest";
11
+ var PACKAGE_NAME = "cursor-oauth-opencode";
12
+ var SCHEMA = "https://opencode.ai/config.json";
13
+ var PROVIDER_ID = "cursor";
14
+ var PROVIDER_API = "http://127.0.0.1:65535/v1";
15
+ var PROVIDER_NPM = "@ai-sdk/openai-compatible";
16
+ var CURSOR_MODELS = {
17
+ "composer-2-fast": {
18
+ name: "Composer 2 Fast",
19
+ reasoning: true,
20
+ temperature: true,
21
+ attachment: false,
22
+ tool_call: true,
23
+ limit: { context: 200000, output: 64000 }
24
+ },
25
+ "composer-2": {
26
+ name: "Composer 2",
27
+ reasoning: true,
28
+ temperature: true,
29
+ attachment: false,
30
+ tool_call: true,
31
+ limit: { context: 200000, output: 64000 }
32
+ },
33
+ "claude-4.6-sonnet": {
34
+ name: "Claude 4.6 Sonnet",
35
+ reasoning: true,
36
+ temperature: true,
37
+ attachment: false,
38
+ tool_call: true,
39
+ limit: { context: 200000, output: 64000 }
40
+ },
41
+ "gpt-5.3-codex": {
42
+ name: "GPT-5.3 Codex",
43
+ reasoning: true,
44
+ temperature: false,
45
+ attachment: false,
46
+ tool_call: true,
47
+ limit: { context: 400000, output: 128000 }
48
+ }
49
+ };
50
+ function globalConfigPath() {
51
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
52
+ return path.join(configHome, "opencode", "opencode.json");
53
+ }
54
+ function projectConfigPath(cwd) {
55
+ return path.join(cwd, "opencode.json");
56
+ }
57
+ function targetPath(options) {
58
+ if (options.configPath)
59
+ return path.resolve(options.configPath);
60
+ if (options.mode === "project")
61
+ return projectConfigPath(options.cwd ?? process.cwd());
62
+ return globalConfigPath();
63
+ }
64
+ function applyJsonPatch(text, jsonPath, value) {
65
+ return applyEdits(text, modify(text, jsonPath, value, {
66
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: `
67
+ ` }
68
+ }));
69
+ }
70
+ function pluginPackageName(specifier) {
71
+ const normalized = specifier.startsWith("npm:") ? specifier.slice(4) : specifier;
72
+ if (normalized.startsWith("file://")) {
73
+ return path.basename(new URL(normalized).pathname).replace(/\.[cm]?[jt]s$/, "");
74
+ }
75
+ const lastAt = normalized.lastIndexOf("@");
76
+ return lastAt > 0 ? normalized.slice(0, lastAt) : normalized;
77
+ }
78
+ function patchConfigText(input, pluginSpec = DEFAULT_PLUGIN_SPEC) {
79
+ let text = input.trim() ? input : "{}";
80
+ const initial = parse(text) ?? {};
81
+ if (!initial.$schema)
82
+ text = applyJsonPatch(text, ["$schema"], SCHEMA);
83
+ const currentPlugins = (parse(text) ?? {}).plugin;
84
+ const plugins = Array.isArray(currentPlugins) ? [...currentPlugins] : [];
85
+ if (!plugins.some((plugin) => typeof plugin === "string" && pluginPackageName(plugin) === PACKAGE_NAME)) {
86
+ plugins.push(pluginSpec);
87
+ }
88
+ text = applyJsonPatch(text, ["plugin"], plugins);
89
+ const next = parse(text) ?? {};
90
+ const existingProvider = next.provider?.[PROVIDER_ID] ?? {};
91
+ const existingModels = existingProvider.models ?? {};
92
+ text = applyJsonPatch(text, ["provider", PROVIDER_ID], {
93
+ ...existingProvider,
94
+ name: existingProvider.name ?? "Cursor",
95
+ npm: existingProvider.npm ?? PROVIDER_NPM,
96
+ api: existingProvider.api ?? PROVIDER_API,
97
+ models: {
98
+ ...CURSOR_MODELS,
99
+ ...existingModels
100
+ }
101
+ });
102
+ return applyEdits(text, format(text, undefined, {
103
+ insertSpaces: true,
104
+ tabSize: 2,
105
+ eol: `
106
+ `
107
+ }));
108
+ }
109
+ async function setupOpenCodeConfig(options = {}) {
110
+ const file = targetPath(options);
111
+ let existing = "";
112
+ try {
113
+ existing = await fs.readFile(file, "utf8");
114
+ } catch (error) {
115
+ if (error?.code !== "ENOENT")
116
+ throw error;
117
+ }
118
+ const updated = patchConfigText(existing, options.pluginSpec ?? DEFAULT_PLUGIN_SPEC);
119
+ await fs.mkdir(path.dirname(file), { recursive: true });
120
+ await fs.writeFile(file, updated.endsWith(`
121
+ `) ? updated : `${updated}
122
+ `, "utf8");
123
+ return file;
124
+ }
125
+ function parseArgs(argv) {
126
+ const options = { mode: "global" };
127
+ for (let index = 0;index < argv.length; index++) {
128
+ const arg = argv[index];
129
+ if (arg === "setup")
130
+ continue;
131
+ if (arg === "--project")
132
+ options.mode = "project";
133
+ else if (arg === "--global")
134
+ options.mode = "global";
135
+ else if (arg === "--path")
136
+ options.configPath = argv[++index];
137
+ else if (arg === "--plugin")
138
+ options.pluginSpec = argv[++index];
139
+ else if (arg === "--help" || arg === "-h") {
140
+ console.log("Usage: cursor-oauth-opencode setup [--global|--project] [--path <opencode.json>] [--plugin <specifier>]");
141
+ process.exit(0);
142
+ }
143
+ }
144
+ return options;
145
+ }
146
+ function isDirectExecution(moduleUrl = import.meta.url, argvPath = process.argv[1]) {
147
+ if (!argvPath)
148
+ return false;
149
+ const modulePath = fileURLToPath(moduleUrl);
150
+ try {
151
+ return realpathSync(modulePath) === realpathSync(argvPath);
152
+ } catch {
153
+ return path.resolve(modulePath) === path.resolve(argvPath);
154
+ }
155
+ }
156
+ if (isDirectExecution()) {
157
+ setupOpenCodeConfig(parseArgs(process.argv.slice(2))).then((file) => {
158
+ console.log(`Updated OpenCode config: ${file}`);
159
+ }).catch((error) => {
160
+ console.error(error instanceof Error ? error.message : String(error));
161
+ process.exit(1);
162
+ });
163
+ }
164
+ export {
165
+ setupOpenCodeConfig,
166
+ patchConfigText,
167
+ isDirectExecution
168
+ };
package/dist/auth.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface CursorAuthParams {
2
+ verifier: string;
3
+ challenge: string;
4
+ uuid: string;
5
+ loginUrl: string;
6
+ }
7
+ export interface CursorCredentials {
8
+ access: string;
9
+ refresh: string;
10
+ expires: number;
11
+ }
12
+ export declare function generateCursorAuthParams(): Promise<CursorAuthParams>;
13
+ export declare function pollCursorAuth(uuid: string, verifier: string): Promise<{
14
+ accessToken: string;
15
+ refreshToken: string;
16
+ }>;
17
+ export declare function refreshCursorToken(refreshToken: string): Promise<CursorCredentials>;
18
+ /**
19
+ * Extract JWT expiry with 5-minute safety margin.
20
+ * Falls back to 1 hour from now if token can't be parsed.
21
+ */
22
+ export declare function getTokenExpiry(token: string): number;
package/dist/auth.js ADDED
@@ -0,0 +1,92 @@
1
+ import { generatePKCE } from "./pkce";
2
+ const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
3
+ const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
4
+ const CURSOR_REFRESH_URL = process.env.CURSOR_REFRESH_URL ??
5
+ "https://api2.cursor.sh/auth/exchange_user_api_key";
6
+ const POLL_MAX_ATTEMPTS = 150;
7
+ const POLL_BASE_DELAY = 1000;
8
+ const POLL_MAX_DELAY = 10_000;
9
+ const POLL_BACKOFF_MULTIPLIER = 1.2;
10
+ export async function generateCursorAuthParams() {
11
+ const { verifier, challenge } = await generatePKCE();
12
+ const uuid = crypto.randomUUID();
13
+ const params = new URLSearchParams({
14
+ challenge,
15
+ uuid,
16
+ mode: "login",
17
+ redirectTarget: "cli",
18
+ });
19
+ const loginUrl = `${CURSOR_LOGIN_URL}?${params.toString()}`;
20
+ return { verifier, challenge, uuid, loginUrl };
21
+ }
22
+ export async function pollCursorAuth(uuid, verifier) {
23
+ let delay = POLL_BASE_DELAY;
24
+ let consecutiveErrors = 0;
25
+ for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
26
+ await Bun.sleep(delay);
27
+ try {
28
+ const response = await fetch(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`);
29
+ if (response.status === 404) {
30
+ consecutiveErrors = 0;
31
+ delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY);
32
+ continue;
33
+ }
34
+ if (response.ok) {
35
+ const data = (await response.json());
36
+ return {
37
+ accessToken: data.accessToken,
38
+ refreshToken: data.refreshToken,
39
+ };
40
+ }
41
+ throw new Error(`Poll failed: ${response.status}`);
42
+ }
43
+ catch {
44
+ consecutiveErrors++;
45
+ if (consecutiveErrors >= 3) {
46
+ throw new Error("Too many consecutive errors during Cursor auth polling");
47
+ }
48
+ }
49
+ }
50
+ throw new Error("Cursor authentication polling timeout");
51
+ }
52
+ export async function refreshCursorToken(refreshToken) {
53
+ const response = await fetch(CURSOR_REFRESH_URL, {
54
+ method: "POST",
55
+ headers: {
56
+ Authorization: `Bearer ${refreshToken}`,
57
+ "Content-Type": "application/json",
58
+ },
59
+ body: "{}",
60
+ });
61
+ if (!response.ok) {
62
+ const error = await response.text();
63
+ throw new Error(`Cursor token refresh failed: ${error}`);
64
+ }
65
+ const data = (await response.json());
66
+ return {
67
+ access: data.accessToken,
68
+ refresh: data.refreshToken || refreshToken,
69
+ expires: getTokenExpiry(data.accessToken),
70
+ };
71
+ }
72
+ /**
73
+ * Extract JWT expiry with 5-minute safety margin.
74
+ * Falls back to 1 hour from now if token can't be parsed.
75
+ */
76
+ export function getTokenExpiry(token) {
77
+ try {
78
+ const parts = token.split(".");
79
+ if (parts.length !== 3 || !parts[1]) {
80
+ return Date.now() + 3600 * 1000;
81
+ }
82
+ const decoded = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
83
+ if (decoded &&
84
+ typeof decoded === "object" &&
85
+ typeof decoded.exp === "number") {
86
+ return decoded.exp * 1000 - 5 * 60 * 1000;
87
+ }
88
+ }
89
+ catch {
90
+ }
91
+ return Date.now() + 3600 * 1000;
92
+ }
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dumb HTTP/2 bidirectional pipe for Cursor gRPC.
4
+ *
5
+ * Bun's node:http2 is broken. This Node script acts as a transparent
6
+ * HTTP/2 proxy: it opens a single bidirectional stream and ferries
7
+ * raw bytes between the parent process (via stdin/stdout) and Cursor.
8
+ *
9
+ * Protocol (length-prefixed framing over stdin/stdout):
10
+ * [4 bytes big-endian length][payload]
11
+ *
12
+ * First message on stdin is JSON config:
13
+ * { "accessToken": "...", "url": "...", "path": "...", "unary": false }
14
+ *
15
+ * When unary=true, the bridge uses application/proto (raw protobuf) instead
16
+ * of application/connect+proto (Connect streaming). The single stdin message
17
+ * is written as the request body and the stream is ended immediately.
18
+ * After config, subsequent stdin messages are raw bytes to write to the H2 stream.
19
+ * H2 response data is written to stdout using the same length-prefixed framing.
20
+ */
21
+ import http2 from "node:http2";
22
+ import crypto from "node:crypto";
23
+
24
+ const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
25
+
26
+ /** Write one length-prefixed message to stdout. */
27
+ function writeMessage(data) {
28
+ const lenBuf = Buffer.alloc(4);
29
+ lenBuf.writeUInt32BE(data.length, 0);
30
+ process.stdout.write(lenBuf);
31
+ process.stdout.write(data);
32
+ }
33
+
34
+ // --- Buffered stdin reader ---
35
+
36
+ let stdinBuf = Buffer.alloc(0);
37
+ let stdinResolve = null;
38
+ let stdinEnded = false;
39
+
40
+ process.stdin.on("data", (chunk) => {
41
+ stdinBuf = Buffer.concat([stdinBuf, chunk]);
42
+ if (stdinResolve) {
43
+ const r = stdinResolve;
44
+ stdinResolve = null;
45
+ r();
46
+ }
47
+ });
48
+
49
+ process.stdin.on("end", () => {
50
+ stdinEnded = true;
51
+ if (stdinResolve) {
52
+ const r = stdinResolve;
53
+ stdinResolve = null;
54
+ r();
55
+ }
56
+ });
57
+
58
+ function waitForData() {
59
+ return new Promise((resolve) => { stdinResolve = resolve; });
60
+ }
61
+
62
+ async function readExact(n) {
63
+ while (stdinBuf.length < n) {
64
+ if (stdinEnded) return null;
65
+ await waitForData();
66
+ }
67
+ const result = stdinBuf.subarray(0, n);
68
+ stdinBuf = stdinBuf.subarray(n);
69
+ return Buffer.from(result);
70
+ }
71
+
72
+ async function readMessage() {
73
+ const lenBuf = await readExact(4);
74
+ if (!lenBuf) return null;
75
+ const len = lenBuf.readUInt32BE(0);
76
+ if (len === 0) return Buffer.alloc(0);
77
+ return readExact(len);
78
+ }
79
+
80
+ // --- Main ---
81
+
82
+ const configBuf = await readMessage();
83
+ if (!configBuf) process.exit(1);
84
+
85
+ const config = JSON.parse(configBuf.toString("utf8"));
86
+ const { accessToken, url, path: rpcPath, unary } = config;
87
+
88
+ const client = http2.connect(url || "https://api2.cursor.sh");
89
+
90
+ // Guard against initial connection failure. Reset on any h2 activity
91
+ // so long-running agent conversations (with tool call round-trips) survive.
92
+ let timeout = setTimeout(killBridge, 30_000);
93
+
94
+ function resetTimeout() {
95
+ clearTimeout(timeout);
96
+ timeout = setTimeout(killBridge, 120_000);
97
+ }
98
+
99
+ function killBridge() {
100
+ clearTimeout(timeout);
101
+ client.destroy();
102
+ process.exit(1);
103
+ }
104
+
105
+ client.on("error", () => {
106
+ clearTimeout(timeout);
107
+ process.exit(1);
108
+ });
109
+
110
+ const headers = {
111
+ ":method": "POST",
112
+ ":path": rpcPath || "/agent.v1.AgentService/Run",
113
+ "content-type": unary ? "application/proto" : "application/connect+proto",
114
+ te: "trailers",
115
+ authorization: `Bearer ${accessToken}`,
116
+ "x-ghost-mode": "true",
117
+ "x-cursor-client-version": CURSOR_CLIENT_VERSION,
118
+ "x-cursor-client-type": "cli",
119
+ "x-request-id": crypto.randomUUID(),
120
+ };
121
+ if (!unary) {
122
+ headers["connect-protocol-version"] = "1";
123
+ }
124
+ const h2Stream = client.request(headers);
125
+
126
+ // Forward H2 response data → stdout (length-prefixed)
127
+ h2Stream.on("data", (chunk) => {
128
+ resetTimeout();
129
+ writeMessage(chunk);
130
+ });
131
+
132
+ h2Stream.on("end", () => {
133
+ clearTimeout(timeout);
134
+ client.close();
135
+ // Give stdout time to flush
136
+ setTimeout(() => process.exit(0), 100);
137
+ });
138
+
139
+ h2Stream.on("error", () => {
140
+ clearTimeout(timeout);
141
+ client.close();
142
+ process.exit(1);
143
+ });
144
+
145
+ // Forward stdin → H2 stream (after config message)
146
+ if (unary) {
147
+ // Unary mode: read a single body message, write it, and end the stream.
148
+ const body = await readMessage();
149
+ if (body && body.length > 0 && !h2Stream.closed && !h2Stream.destroyed) {
150
+ h2Stream.end(body);
151
+ } else {
152
+ h2Stream.end();
153
+ }
154
+ } else {
155
+ // Streaming mode: forward all stdin messages as Connect frames.
156
+ (async () => {
157
+ while (true) {
158
+ const msg = await readMessage();
159
+ if (!msg || msg.length === 0) {
160
+ // EOF or zero-length = done writing
161
+ break;
162
+ }
163
+ if (!h2Stream.closed && !h2Stream.destroyed) {
164
+ resetTimeout();
165
+ h2Stream.write(msg);
166
+ }
167
+ }
168
+
169
+ if (!h2Stream.closed && !h2Stream.destroyed) {
170
+ h2Stream.end();
171
+ }
172
+ })();
173
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OpenCode Cursor Auth Plugin
3
+ *
4
+ * Enables using Cursor models (Claude, GPT, etc.) inside OpenCode via:
5
+ * 1. Browser-based OAuth login to Cursor
6
+ * 2. Local proxy translating OpenAI format → Cursor gRPC protocol
7
+ */
8
+ import type { Plugin } from "@opencode-ai/plugin";
9
+ /**
10
+ * OpenCode plugin that provides Cursor authentication and model access.
11
+ * Register in opencode.json: { "plugin": ["cursor-oauth-opencode"] }
12
+ */
13
+ export declare const CursorAuthPlugin: Plugin;
14
+ export default CursorAuthPlugin;