cursor-opencode-provider 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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/auth.d.ts +48 -0
  4. package/dist/auth.js +200 -0
  5. package/dist/context/agents.d.ts +8 -0
  6. package/dist/context/agents.js +76 -0
  7. package/dist/context/build.d.ts +12 -0
  8. package/dist/context/build.js +83 -0
  9. package/dist/context/env.d.ts +1 -0
  10. package/dist/context/env.js +29 -0
  11. package/dist/context/git.d.ts +20 -0
  12. package/dist/context/git.js +59 -0
  13. package/dist/context/index.d.ts +2 -0
  14. package/dist/context/index.js +2 -0
  15. package/dist/context/layout.d.ts +17 -0
  16. package/dist/context/layout.js +58 -0
  17. package/dist/context/paths.d.ts +3 -0
  18. package/dist/context/paths.js +11 -0
  19. package/dist/context/plugins.d.ts +8 -0
  20. package/dist/context/plugins.js +50 -0
  21. package/dist/context/rules.d.ts +19 -0
  22. package/dist/context/rules.js +198 -0
  23. package/dist/context/skills.d.ts +11 -0
  24. package/dist/context/skills.js +104 -0
  25. package/dist/index.d.ts +15 -0
  26. package/dist/index.js +18 -0
  27. package/dist/language-model.d.ts +45 -0
  28. package/dist/language-model.js +834 -0
  29. package/dist/models.d.ts +49 -0
  30. package/dist/models.js +136 -0
  31. package/dist/plugin-v2.d.ts +2 -0
  32. package/dist/plugin-v2.js +48 -0
  33. package/dist/plugin.d.ts +2 -0
  34. package/dist/plugin.js +201 -0
  35. package/dist/protocol/blob-store.d.ts +15 -0
  36. package/dist/protocol/blob-store.js +52 -0
  37. package/dist/protocol/checkpoint.d.ts +17 -0
  38. package/dist/protocol/checkpoint.js +29 -0
  39. package/dist/protocol/checksum.d.ts +2 -0
  40. package/dist/protocol/checksum.js +23 -0
  41. package/dist/protocol/client-version.d.ts +5 -0
  42. package/dist/protocol/client-version.js +150 -0
  43. package/dist/protocol/device-id.d.ts +8 -0
  44. package/dist/protocol/device-id.js +121 -0
  45. package/dist/protocol/framing.d.ts +10 -0
  46. package/dist/protocol/framing.js +90 -0
  47. package/dist/protocol/kv.d.ts +24 -0
  48. package/dist/protocol/kv.js +81 -0
  49. package/dist/protocol/messages.d.ts +11 -0
  50. package/dist/protocol/messages.js +676 -0
  51. package/dist/protocol/request.d.ts +36 -0
  52. package/dist/protocol/request.js +90 -0
  53. package/dist/protocol/stream.d.ts +38 -0
  54. package/dist/protocol/stream.js +64 -0
  55. package/dist/protocol/struct.d.ts +19 -0
  56. package/dist/protocol/struct.js +186 -0
  57. package/dist/protocol/thinking.d.ts +15 -0
  58. package/dist/protocol/thinking.js +17 -0
  59. package/dist/protocol/tools.d.ts +94 -0
  60. package/dist/protocol/tools.js +631 -0
  61. package/dist/session.d.ts +81 -0
  62. package/dist/session.js +96 -0
  63. package/dist/shared.d.ts +15 -0
  64. package/dist/shared.js +13 -0
  65. package/dist/transport/connect.d.ts +23 -0
  66. package/dist/transport/connect.js +275 -0
  67. package/package.json +65 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleksiy Akimov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # cursor-opencode-provider
2
+
3
+ Use [Cursor](https://cursor.com) subscription models from [OpenCode](https://opencode.ai) by speaking Cursor's Connect-RPC agent protocol.
4
+
5
+ This project is a custom **AI SDK provider** (`LanguageModelV3`) plus an **OpenCode plugin** that handles authentication and model discovery. Instead of calling a generic chat-completions API, it encodes and decodes Cursor's protobuf agent protocol over HTTP/2 to `agentn.global.api5.cursor.sh`.
6
+
7
+ > **Status:** Usable end-to-end in OpenCode (auth, models, streaming, tools). See [Known limitations](#known-limitations).
8
+
9
+ ## Features
10
+
11
+ - **OpenCode integration** — registers a `cursor` provider with auth hooks and cached model list
12
+ - **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
13
+ - **Model discovery** — fetches available models from Cursor's API and caches them locally
14
+ - **Streaming** — bidirectional Connect-RPC stream for agent runs
15
+ - **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts
16
+ - **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
17
+
18
+ ## Requirements
19
+
20
+ - [Bun](https://bun.sh) (for development and tests)
21
+ - [OpenCode](https://opencode.ai) with plugin support
22
+ - An active Cursor account with API access
23
+
24
+ ## Installation
25
+
26
+ ### From npm (after publish)
27
+
28
+ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun at startup (cached under `~/.cache/opencode/node_modules/`):
29
+
30
+ ```json
31
+ {
32
+ "$schema": "https://opencode.ai/config.json",
33
+ "plugin": ["cursor-opencode-provider"],
34
+ "provider": {
35
+ "cursor": {
36
+ "npm": "cursor-opencode-provider",
37
+ "name": "Cursor",
38
+ "models": {}
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ Pin a version if you want: `"cursor-opencode-provider@0.1.1"`.
45
+
46
+ ### From a local clone
47
+
48
+ ```bash
49
+ git clone https://github.com/oakimov/cursor-opencode-provider.git
50
+ cd cursor-opencode-provider
51
+ bun install
52
+ bun run build
53
+ ```
54
+
55
+ Point config at the built files with absolute `file://` URLs:
56
+
57
+ ```json
58
+ {
59
+ "plugin": ["file:///absolute/path/to/cursor-opencode-provider/dist/plugin.js"],
60
+ "provider": {
61
+ "cursor": {
62
+ "npm": "file:///absolute/path/to/cursor-opencode-provider/dist/index.js",
63
+ "name": "Cursor",
64
+ "models": {}
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ## OpenCode setup
71
+
72
+ If the `cursor` provider block is omitted, the classic plugin auto-registers it on startup (as **Cursor Integration**) using this package's entry. Model entries are filled from the local cache after you authenticate.
73
+
74
+ For OpenCode builds that use the Effect/Promise **v2** plugin API (`plugins` field), also load:
75
+
76
+ ```json
77
+ {
78
+ "plugins": ["cursor-opencode-provider/plugin/v2"]
79
+ }
80
+ ```
81
+
82
+ Local clone equivalent: `"file:///absolute/path/to/cursor-opencode-provider/dist/plugin-v2.js"`.
83
+
84
+ That entry registers the provider via `ctx.aisdk.sdk` / `ctx.aisdk.language`. Keep the classic `plugin` entry for auth.
85
+
86
+ ### Authenticate
87
+
88
+ ```bash
89
+ opencode auth login
90
+ ```
91
+
92
+ Choose the **cursor** provider, then one of:
93
+
94
+ | Method | Description |
95
+ |--------|-------------|
96
+ | **Cursor account (browser login)** | PKCE OAuth — opens cursor.com to sign in |
97
+ | **API key** | Paste a key from [cursor.com/settings](https://cursor.com/settings) (`sk-...`) |
98
+
99
+ After login, the plugin fetches your available models and writes them to `cursor-models.json` (OpenCode config directory, or `CURSOR_CONFIG_DIR` if set).
100
+
101
+ ### Select a model
102
+
103
+ Pick a model from the cached list (for example `composer-2.5`, `default`, or a Claude/GPT variant exposed by your subscription):
104
+
105
+ ```bash
106
+ opencode run --model cursor/composer-2.5 "Hello from Cursor via OpenCode"
107
+ ```
108
+
109
+ ## Programmatic usage
110
+
111
+ ```ts
112
+ import { createCursor } from "cursor-opencode-provider"
113
+
114
+ const cursor = createCursor({
115
+ name: "cursor",
116
+ accessToken: process.env.CURSOR_ACCESS_TOKEN,
117
+ })
118
+
119
+ const model = cursor.languageModel("composer-2.5")
120
+ // model implements AI SDK LanguageModelV3 (doStream / doGenerate)
121
+ ```
122
+
123
+ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `baseURL`, `headers`.
124
+
125
+ ## Environment variables
126
+
127
+ | Variable | Description |
128
+ |----------|-------------|
129
+ | `CURSOR_CONFIG_DIR` | Override directory for `cursor-models.json` cache (defaults to the OpenCode directory passed into the plugin) |
130
+ | `CURSOR_WEBSITE_URL` | Override OAuth login base URL (default `https://cursor.com`) |
131
+ | `CURSOR_API_BASE_URL` | Override API base for auth and model discovery (default `https://api2.cursor.sh`) |
132
+ | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
133
+ | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default `/tmp/cursor-provider-debug.log`) |
134
+
135
+ `createCursor({ baseURL })` also overrides the agent Run host (default `https://agentn.global.api5.cursor.sh`).
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ bun install # install dependencies
141
+ bun run build # compile TypeScript → dist/
142
+ bun run typecheck # type-check without emit
143
+ bun test # run unit tests
144
+ bun run test:watch # watch mode
145
+ ```
146
+
147
+ ## Architecture
148
+
149
+ ```
150
+ OpenCode
151
+ └── CursorPlugin (auth, model cache, config hook)
152
+ └── createCursor() → LanguageModelV3
153
+ ├── session.ts held-open Run stream + exec bridge
154
+ ├── protocol/ protobuf messages, framing, tools, thinking
155
+ └── transport/ Connect-RPC over HTTP/2 to agentn.global.api5.cursor.sh
156
+ ```
157
+
158
+ | Module | Role |
159
+ |--------|------|
160
+ | `src/plugin.ts` | Classic OpenCode hooks: provider registration, OAuth, API key exchange, token refresh |
161
+ | `src/plugin-v2.ts` | OpenCode Effect/Promise v2 plugin (`ctx.aisdk.*`); load via `./plugin/v2` only |
162
+ | `src/index.ts` | `createCursor` factory; default export is `CursorPlugin` |
163
+ | `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
164
+ | `src/session.ts` | Held-open agent Run session and pending exec correlation |
165
+ | `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
166
+ | `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
167
+ | `src/transport/connect.ts` | HTTP/2 bidi stream and unary RPC calls |
168
+ | `src/protocol/` | Protobuf encode/decode, checksum/device ids, tool-call mapping |
169
+
170
+ ## Package exports
171
+
172
+ | Import path | Export |
173
+ |-------------|--------|
174
+ | `cursor-opencode-provider` | `createCursor`, `CursorPlugin` (named + default) |
175
+ | `cursor-opencode-provider/plugin` | `CursorPlugin` (classic Hooks — auth) |
176
+ | `cursor-opencode-provider/plugin/v2` | OpenCode Effect/Promise v2 plugin (`ctx.aisdk.*`) |
177
+
178
+ `CursorPluginV2` is **not** re-exported from the package root — the classic plugin loader would treat it as a broken Hooks export. Always load it via `./plugin/v2`.
179
+
180
+ ## Troubleshooting
181
+
182
+ | Problem | What to try |
183
+ |---------|-------------|
184
+ | No Cursor models in the picker | Run `opencode auth login`, choose **cursor**, then restart OpenCode so the plugin reloads `cursor-models.json`. Confirm `provider.cursor.npm` is the package name (or a built `file://…/dist/index.js`). |
185
+ | Auth / 401 errors mid-session | Re-login. OAuth and exchanged API-key JWTs refresh automatically when near expiry; a revoked refresh token needs a fresh login. |
186
+ | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
187
+ | Empty or stale model list | Delete `cursor-models.json` under the OpenCode config dir (or the dir set by `CURSOR_CONFIG_DIR`) and re-auth / restart so models are fetched again. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
188
+ | Stream hangs or HTTP/2 errors | Abort the turn and retry. The agent Run uses a bidirectional HTTP/2 stream to `agentn.global.api5.cursor.sh`; a dropped connection leaves the in-flight session unusable. |
189
+ | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`, default `/tmp/cursor-provider-debug.log`) and reproduce the issue. |
190
+
191
+ ## Known limitations
192
+
193
+ - **Personal use / ToS** — this provider speaks Cursor’s private agent protocol (CLI-shaped client identity). Use only with an account you own; Cursor may change or restrict the API without notice.
194
+ - **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted.
195
+ - **Tool-call display stub** — semantic `tool_call_started` frames are only partially decoded. Tool execution itself goes through the exec channel and works; UI that relied solely on the display type would be incomplete.
196
+ - **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
197
+
198
+ ## License
199
+
200
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ export declare class AuthExchangeError extends Error {
2
+ cause?: unknown | undefined;
3
+ constructor(message: string, cause?: unknown | undefined);
4
+ }
5
+ export declare class AuthRefreshError extends Error {
6
+ cause?: unknown | undefined;
7
+ constructor(message: string, cause?: unknown | undefined);
8
+ }
9
+ export declare class AuthPollError extends Error {
10
+ cause?: unknown | undefined;
11
+ constructor(message: string, cause?: unknown | undefined);
12
+ }
13
+ export declare class AuthTimeoutError extends Error {
14
+ constructor(message: string);
15
+ }
16
+ export declare function isExpiringSoon(jwt: string, thresholdS?: number): boolean;
17
+ export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
18
+ export declare function useAuthToken(token: string): {
19
+ accessToken: string;
20
+ };
21
+ export type TokenPair = {
22
+ accessToken: string;
23
+ refreshToken: string;
24
+ };
25
+ export declare function exchangeApiKey(apiKey: string, baseUrl?: string): Promise<TokenPair>;
26
+ export declare function refreshAccessToken(refreshToken: string, baseUrl?: string): Promise<TokenPair>;
27
+ /** Clear the apiKey→JWT cache (tests). */
28
+ export declare function clearBearerTokenCache(): void;
29
+ /**
30
+ * Resolve a Bearer JWT for Cursor API calls. Prefer an already-exchanged
31
+ * `accessToken`; otherwise exchange (and cache) from `apiKey`, refreshing
32
+ * when the cached JWT is near expiry.
33
+ */
34
+ export declare function resolveBearerToken(input: {
35
+ accessToken?: string;
36
+ apiKey?: string;
37
+ baseUrl?: string;
38
+ }): Promise<string>;
39
+ export type PkceParams = {
40
+ verifier: string;
41
+ challenge: string;
42
+ uuid: string;
43
+ };
44
+ export declare function generatePkceParams(): PkceParams;
45
+ export declare function generatePkceChallenge(verifier: string): Promise<string>;
46
+ export declare function buildLoginUrl(challenge: string, uuid: string, websiteUrl?: string): string;
47
+ export declare function pollForTokens(uuid: string, verifier: string, baseUrl?: string, signal?: AbortSignal, maxAttempts?: number): Promise<TokenPair>;
48
+ export declare function loginWithBrowser(websiteUrl?: string, apiBaseUrl?: string, signal?: AbortSignal): Promise<TokenPair>;
package/dist/auth.js ADDED
@@ -0,0 +1,200 @@
1
+ import { CURSOR_API_HOST, CURSOR_WEBSITE_HOST } from "./shared.js";
2
+ const API_BASE = `https://${CURSOR_API_HOST}`;
3
+ export class AuthExchangeError extends Error {
4
+ cause;
5
+ constructor(message, cause) {
6
+ super(message);
7
+ this.cause = cause;
8
+ this.name = "AuthExchangeError";
9
+ }
10
+ }
11
+ export class AuthRefreshError extends Error {
12
+ cause;
13
+ constructor(message, cause) {
14
+ super(message);
15
+ this.cause = cause;
16
+ this.name = "AuthRefreshError";
17
+ }
18
+ }
19
+ export class AuthPollError extends Error {
20
+ cause;
21
+ constructor(message, cause) {
22
+ super(message);
23
+ this.cause = cause;
24
+ this.name = "AuthPollError";
25
+ }
26
+ }
27
+ export class AuthTimeoutError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = "AuthTimeoutError";
31
+ }
32
+ }
33
+ // ── Helpers ──
34
+ export function isExpiringSoon(jwt, thresholdS = 300) {
35
+ try {
36
+ const payload = JSON.parse(atob(jwt.split(".")[1]));
37
+ return (payload.exp * 1000 - Date.now()) < thresholdS * 1000;
38
+ }
39
+ catch {
40
+ return true;
41
+ }
42
+ }
43
+ export function decodeJwtPayload(jwt) {
44
+ try {
45
+ return JSON.parse(atob(jwt.split(".")[1]));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function base64url(bytes) {
52
+ return btoa(String.fromCharCode(...bytes))
53
+ .replace(/\+/g, "-")
54
+ .replace(/\//g, "_")
55
+ .replace(/=+$/, "");
56
+ }
57
+ // ── Mode A: pass-through auth token ──
58
+ export function useAuthToken(token) {
59
+ return { accessToken: token };
60
+ }
61
+ export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
62
+ const res = await fetch(`${baseUrl}/auth/exchange_user_api_key`, {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ Authorization: `Bearer ${apiKey}`,
67
+ },
68
+ body: "{}",
69
+ });
70
+ if (!res.ok) {
71
+ throw new AuthExchangeError(`API key exchange failed: ${res.status} ${res.statusText}`);
72
+ }
73
+ const body = await res.json();
74
+ if (!body.accessToken || !body.refreshToken) {
75
+ throw new AuthExchangeError("Exchange response missing tokens");
76
+ }
77
+ return { accessToken: body.accessToken, refreshToken: body.refreshToken };
78
+ }
79
+ export async function refreshAccessToken(refreshToken, baseUrl = API_BASE) {
80
+ const res = await fetch(`${baseUrl}/auth/token`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify({ refreshToken }),
84
+ });
85
+ if (!res.ok) {
86
+ throw new AuthRefreshError(`Token refresh failed: ${res.status} ${res.statusText}`);
87
+ }
88
+ const body = await res.json();
89
+ if (!body.accessToken || !body.refreshToken) {
90
+ throw new AuthRefreshError("Refresh response missing tokens");
91
+ }
92
+ return { accessToken: body.accessToken, refreshToken: body.refreshToken };
93
+ }
94
+ // Cache JWTs obtained via API-key exchange so doStream doesn't re-exchange
95
+ // on every turn when the caller only supplied `apiKey`.
96
+ const _apiKeyTokenCache = new Map();
97
+ /** Clear the apiKey→JWT cache (tests). */
98
+ export function clearBearerTokenCache() {
99
+ _apiKeyTokenCache.clear();
100
+ }
101
+ /**
102
+ * Resolve a Bearer JWT for Cursor API calls. Prefer an already-exchanged
103
+ * `accessToken`; otherwise exchange (and cache) from `apiKey`, refreshing
104
+ * when the cached JWT is near expiry.
105
+ */
106
+ export async function resolveBearerToken(input) {
107
+ if (input.accessToken)
108
+ return input.accessToken;
109
+ if (!input.apiKey) {
110
+ throw new Error("Cursor provider: no access token or API key provided");
111
+ }
112
+ const baseUrl = input.baseUrl ?? API_BASE;
113
+ const cached = _apiKeyTokenCache.get(input.apiKey);
114
+ if (cached && !isExpiringSoon(cached.accessToken)) {
115
+ return cached.accessToken;
116
+ }
117
+ if (cached) {
118
+ try {
119
+ const refreshed = await refreshAccessToken(cached.refreshToken, baseUrl);
120
+ _apiKeyTokenCache.set(input.apiKey, refreshed);
121
+ return refreshed.accessToken;
122
+ }
123
+ catch {
124
+ // Fall through to a fresh exchange.
125
+ }
126
+ }
127
+ const pair = await exchangeApiKey(input.apiKey, baseUrl);
128
+ _apiKeyTokenCache.set(input.apiKey, pair);
129
+ return pair.accessToken;
130
+ }
131
+ // ── Mode C: PKCE browser login ──
132
+ async function sha256(data) {
133
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
134
+ }
135
+ export function generatePkceParams() {
136
+ const verifierBytes = new Uint8Array(32);
137
+ crypto.getRandomValues(verifierBytes);
138
+ const verifier = base64url(verifierBytes);
139
+ const uuid = crypto.randomUUID();
140
+ return { verifier, challenge: "", uuid }; // challenge filled async
141
+ }
142
+ export async function generatePkceChallenge(verifier) {
143
+ const enc = new TextEncoder();
144
+ const hash = await sha256(enc.encode(verifier));
145
+ return base64url(hash);
146
+ }
147
+ export function buildLoginUrl(challenge, uuid, websiteUrl = `https://${CURSOR_WEBSITE_HOST}`) {
148
+ return `${websiteUrl}/loginDeepControl?challenge=${encodeURIComponent(challenge)}&uuid=${encodeURIComponent(uuid)}&mode=login&redirectTarget=cli`;
149
+ }
150
+ export async function pollForTokens(uuid, verifier, baseUrl = API_BASE, signal, maxAttempts = 150) {
151
+ let failures = 0;
152
+ for (let i = 0; i < maxAttempts; i++) {
153
+ if (signal?.aborted)
154
+ throw new AuthTimeoutError("Poll cancelled");
155
+ const delay = Math.min(1000 * Math.pow(1.2, i), 10000);
156
+ await new Promise((r) => setTimeout(r, delay));
157
+ try {
158
+ const url = `${baseUrl}/auth/poll?uuid=${encodeURIComponent(uuid)}&verifier=${encodeURIComponent(verifier)}`;
159
+ const res = await fetch(url);
160
+ if (res.status === 404) {
161
+ failures = 0;
162
+ continue;
163
+ }
164
+ if (!res.ok) {
165
+ failures++;
166
+ if (failures >= 3) {
167
+ throw new AuthPollError(`Poll failed after ${failures} consecutive errors (last: ${res.status})`);
168
+ }
169
+ continue;
170
+ }
171
+ const body = await res.json();
172
+ if (body.accessToken && body.refreshToken) {
173
+ return { accessToken: body.accessToken, refreshToken: body.refreshToken };
174
+ }
175
+ // 200 OK but missing tokens — same fail-fast policy as the other error
176
+ // paths, otherwise a partial-body server response stalls the full
177
+ // ~5-minute poll budget with no diagnostic.
178
+ failures++;
179
+ if (failures >= 3) {
180
+ throw new AuthPollError("Poll returned 3 consecutive 200 responses without tokens");
181
+ }
182
+ }
183
+ catch (err) {
184
+ if (err instanceof AuthPollError)
185
+ throw err;
186
+ failures++;
187
+ if (failures >= 3) {
188
+ throw new AuthPollError("Poll failed after 3 consecutive network errors", err);
189
+ }
190
+ }
191
+ }
192
+ throw new AuthTimeoutError(`Poll timed out after ${maxAttempts} attempts (~5 min)`);
193
+ }
194
+ // ── Combined login (Mode C, one-shot) ──
195
+ export async function loginWithBrowser(websiteUrl, apiBaseUrl, signal) {
196
+ const params = generatePkceParams();
197
+ const challenge = await generatePkceChallenge(params.verifier);
198
+ const loginUrl = buildLoginUrl(challenge, params.uuid, websiteUrl);
199
+ return await pollForTokens(params.uuid, params.verifier, apiBaseUrl, signal);
200
+ }
@@ -0,0 +1,8 @@
1
+ export type CollectedAgent = {
2
+ fullPath: string;
3
+ name: string;
4
+ description: string;
5
+ prompt: string;
6
+ };
7
+ /** Custom OpenCode agents from `.opencode/agents` and global config. */
8
+ export declare function collectAgents(workspaceRoot: string): Promise<CollectedAgent[]>;
@@ -0,0 +1,76 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { opencodeGlobalConfigDir } from "./paths.js";
4
+ async function exists(p) {
5
+ try {
6
+ await stat(p);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function parseAgentMarkdown(raw, fallbackName) {
14
+ let name = fallbackName;
15
+ let description = "";
16
+ let body = raw;
17
+ if (raw.startsWith("---")) {
18
+ const end = raw.indexOf("\n---", 3);
19
+ if (end >= 0) {
20
+ const fm = raw.slice(3, end).trim();
21
+ body = raw.slice(end + 4).replace(/^\n/, "");
22
+ for (const line of fm.split("\n")) {
23
+ const m = line.match(/^(\w+):\s*(.*)$/);
24
+ if (!m)
25
+ continue;
26
+ let val = m[2].trim();
27
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
28
+ val = val.slice(1, -1);
29
+ }
30
+ if (m[1] === "name")
31
+ name = val;
32
+ if (m[1] === "description")
33
+ description = val;
34
+ }
35
+ }
36
+ }
37
+ return { name, description, prompt: body.slice(0, 20_000) };
38
+ }
39
+ async function scanAgentDir(dir, out) {
40
+ if (!(await exists(dir)))
41
+ return;
42
+ let entries;
43
+ try {
44
+ entries = await readdir(dir);
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ for (const name of entries) {
50
+ if (!name.endsWith(".md"))
51
+ continue;
52
+ const full = path.join(dir, name);
53
+ try {
54
+ const raw = await readFile(full, "utf-8");
55
+ const parsed = parseAgentMarkdown(raw, name.replace(/\.md$/, ""));
56
+ if (!out.has(parsed.name)) {
57
+ out.set(parsed.name, {
58
+ fullPath: path.resolve(full),
59
+ name: parsed.name,
60
+ description: parsed.description,
61
+ prompt: parsed.prompt,
62
+ });
63
+ }
64
+ }
65
+ catch {
66
+ /* skip */
67
+ }
68
+ }
69
+ }
70
+ /** Custom OpenCode agents from `.opencode/agents` and global config. */
71
+ export async function collectAgents(workspaceRoot) {
72
+ const out = new Map();
73
+ await scanAgentDir(path.join(workspaceRoot, ".opencode", "agents"), out);
74
+ await scanAgentDir(path.join(opencodeGlobalConfigDir(), "agents"), out);
75
+ return [...out.values()];
76
+ }
@@ -0,0 +1,12 @@
1
+ import { type OpencodeToolDef } from "../protocol/tools.js";
2
+ export type BuildRequestContextInput = {
3
+ workspaceRoot: string;
4
+ tools?: OpencodeToolDef[];
5
+ providerIdentifier?: string;
6
+ };
7
+ /**
8
+ * Full RequestContext payload for live UMA + exec #10 reply.
9
+ * Sourced from OpenCode discovery (and .claude/.agents skill fallbacks).
10
+ * Honors `instructions` globs the same way OpenCode does (including `.cursor/` if listed).
11
+ */
12
+ export declare function buildRequestContext(input: BuildRequestContextInput): Promise<Record<string, unknown>>;
@@ -0,0 +1,83 @@
1
+ import path from "node:path";
2
+ import { toolsToDescriptors, toolsToMcpDescriptors } from "../protocol/tools.js";
3
+ import { collectRules } from "./rules.js";
4
+ import { collectSkills } from "./skills.js";
5
+ import { collectAgents } from "./agents.js";
6
+ import { collectPlugins } from "./plugins.js";
7
+ import { collectGit } from "./git.js";
8
+ import { collectProjectLayout } from "./layout.js";
9
+ import { buildEnv } from "./env.js";
10
+ /**
11
+ * Full RequestContext payload for live UMA + exec #10 reply.
12
+ * Sourced from OpenCode discovery (and .claude/.agents skill fallbacks).
13
+ * Honors `instructions` globs the same way OpenCode does (including `.cursor/` if listed).
14
+ */
15
+ export async function buildRequestContext(input) {
16
+ const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
17
+ const providerIdentifier = input.providerIdentifier ?? "opencode";
18
+ const tools = input.tools ?? [];
19
+ const { rules, config, worktree } = await collectRules(workspaceRoot);
20
+ const [skills, agents, plugins, git, layout] = await Promise.all([
21
+ collectSkills(workspaceRoot, worktree),
22
+ collectAgents(workspaceRoot),
23
+ collectPlugins(workspaceRoot, config),
24
+ collectGit(workspaceRoot),
25
+ collectProjectLayout(workspaceRoot),
26
+ ]);
27
+ const flat = toolsToDescriptors(tools, providerIdentifier);
28
+ const nested = toolsToMcpDescriptors(tools, providerIdentifier);
29
+ const permission = config.permission;
30
+ const autoRun = permission === "allow" ||
31
+ (typeof permission === "object" &&
32
+ permission !== null &&
33
+ permission["*"] === "allow");
34
+ const ctx = {
35
+ env: buildEnv(workspaceRoot),
36
+ tools: flat,
37
+ rules: rules.map((r) => ({
38
+ full_path: r.fullPath,
39
+ content: r.content,
40
+ })),
41
+ repository_info: git.repositoryInfo,
42
+ git_repos: git.gitRepos,
43
+ project_layouts: [layout],
44
+ agent_skills: skills.map((s) => ({
45
+ full_path: s.fullPath,
46
+ content: s.content,
47
+ description: s.description,
48
+ })),
49
+ custom_subagents: agents.map((a) => ({
50
+ full_path: a.fullPath,
51
+ name: a.name,
52
+ description: a.description,
53
+ prompt: a.prompt,
54
+ })),
55
+ mcp_file_system_options: {
56
+ enabled: true,
57
+ workspace_project_dir: workspaceRoot,
58
+ mcp_descriptors: nested,
59
+ },
60
+ mcp_meta_tool_options: {
61
+ enabled: true,
62
+ mcp_descriptors: nested,
63
+ },
64
+ // Completeness: true only for sections we actually gathered.
65
+ rules_info_complete: true,
66
+ env_info_complete: true,
67
+ repository_info_complete: true,
68
+ git_repo_info_complete: true,
69
+ git_status_info_complete: true,
70
+ agent_skills_info_complete: true,
71
+ custom_subagents_info_complete: true,
72
+ mcp_file_system_info_complete: true,
73
+ mcp_info_complete: true,
74
+ user_permissions_auto_run: autoRun,
75
+ project_permissions_auto_run: autoRun,
76
+ };
77
+ if (plugins.length > 0) {
78
+ ctx.hooks_additional_context = plugins
79
+ .map((p) => `opencode-plugin:${p.source}:${p.id}`)
80
+ .join("\n");
81
+ }
82
+ return ctx;
83
+ }
@@ -0,0 +1 @@
1
+ export declare function buildEnv(workspaceRoot: string): Record<string, unknown>;
@@ -0,0 +1,29 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ export function buildEnv(workspaceRoot) {
4
+ const cwd = path.resolve(workspaceRoot);
5
+ let timeZone = "UTC";
6
+ try {
7
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
8
+ }
9
+ catch {
10
+ /* keep UTC */
11
+ }
12
+ const home = homedir();
13
+ const osVersion = (() => {
14
+ const p = process.platform;
15
+ const r = process.release?.version;
16
+ return r ? `${p} ${r}` : p;
17
+ })();
18
+ return {
19
+ os_version: osVersion,
20
+ workspace_paths: [cwd],
21
+ shell: process.env.SHELL || "/bin/bash",
22
+ sandbox_enabled: false,
23
+ sandbox_supported: false,
24
+ time_zone: timeZone,
25
+ project_folder: cwd,
26
+ process_working_directory: process.cwd(),
27
+ is_working_dir_home_dir: path.resolve(process.cwd()) === path.resolve(home),
28
+ };
29
+ }