sub-bridge 1.0.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.
Files changed (163) hide show
  1. package/.cursor/commands/mcp-only.md +1 -0
  2. package/.github/workflows/npm-publish.yml +33 -0
  3. package/.github/workflows/pages.yml +40 -0
  4. package/.github/workflows/release-please.yml +21 -0
  5. package/.release-please-manifest.json +3 -0
  6. package/CHANGELOG.md +8 -0
  7. package/DEVELOPMENT.md +31 -0
  8. package/LICENSE +21 -0
  9. package/README.md +87 -0
  10. package/api/index.ts +12 -0
  11. package/bun.lock +102 -0
  12. package/dist/auth/oauth-flow.d.ts +24 -0
  13. package/dist/auth/oauth-flow.d.ts.map +1 -0
  14. package/dist/auth/oauth-flow.js +184 -0
  15. package/dist/auth/oauth-flow.js.map +1 -0
  16. package/dist/auth/oauth-manager.d.ts +13 -0
  17. package/dist/auth/oauth-manager.d.ts.map +1 -0
  18. package/dist/auth/oauth-manager.js +25 -0
  19. package/dist/auth/oauth-manager.js.map +1 -0
  20. package/dist/auth/provider.d.ts +42 -0
  21. package/dist/auth/provider.d.ts.map +1 -0
  22. package/dist/auth/provider.js +270 -0
  23. package/dist/auth/provider.js.map +1 -0
  24. package/dist/cli.d.ts +3 -0
  25. package/dist/cli.d.ts.map +1 -0
  26. package/dist/cli.js +91 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/mcp/proxy.d.ts +16 -0
  29. package/dist/mcp/proxy.d.ts.map +1 -0
  30. package/dist/mcp/proxy.js +85 -0
  31. package/dist/mcp/proxy.js.map +1 -0
  32. package/dist/mcp.d.ts +3 -0
  33. package/dist/mcp.d.ts.map +1 -0
  34. package/dist/mcp.js +50 -0
  35. package/dist/mcp.js.map +1 -0
  36. package/dist/routes/auth.d.ts +6 -0
  37. package/dist/routes/auth.d.ts.map +1 -0
  38. package/dist/routes/auth.js +149 -0
  39. package/dist/routes/auth.js.map +1 -0
  40. package/dist/routes/chat.d.ts +6 -0
  41. package/dist/routes/chat.d.ts.map +1 -0
  42. package/dist/routes/chat.js +808 -0
  43. package/dist/routes/chat.js.map +1 -0
  44. package/dist/routes/tunnels.d.ts +7 -0
  45. package/dist/routes/tunnels.d.ts.map +1 -0
  46. package/dist/routes/tunnels.js +44 -0
  47. package/dist/routes/tunnels.js.map +1 -0
  48. package/dist/server.d.ts +25 -0
  49. package/dist/server.d.ts.map +1 -0
  50. package/dist/server.js +157 -0
  51. package/dist/server.js.map +1 -0
  52. package/dist/tunnel/providers/cloudflare.d.ts +9 -0
  53. package/dist/tunnel/providers/cloudflare.d.ts.map +1 -0
  54. package/dist/tunnel/providers/cloudflare.js +47 -0
  55. package/dist/tunnel/providers/cloudflare.js.map +1 -0
  56. package/dist/tunnel/providers/index.d.ts +4 -0
  57. package/dist/tunnel/providers/index.d.ts.map +1 -0
  58. package/dist/tunnel/providers/index.js +13 -0
  59. package/dist/tunnel/providers/index.js.map +1 -0
  60. package/dist/tunnel/providers/ngrok.d.ts +10 -0
  61. package/dist/tunnel/providers/ngrok.d.ts.map +1 -0
  62. package/dist/tunnel/providers/ngrok.js +52 -0
  63. package/dist/tunnel/providers/ngrok.js.map +1 -0
  64. package/dist/tunnel/providers/tailscale.d.ts +10 -0
  65. package/dist/tunnel/providers/tailscale.d.ts.map +1 -0
  66. package/dist/tunnel/providers/tailscale.js +48 -0
  67. package/dist/tunnel/providers/tailscale.js.map +1 -0
  68. package/dist/tunnel/registry.d.ts +14 -0
  69. package/dist/tunnel/registry.d.ts.map +1 -0
  70. package/dist/tunnel/registry.js +86 -0
  71. package/dist/tunnel/registry.js.map +1 -0
  72. package/dist/tunnel/types.d.ts +26 -0
  73. package/dist/tunnel/types.d.ts.map +1 -0
  74. package/dist/tunnel/types.js +6 -0
  75. package/dist/tunnel/types.js.map +1 -0
  76. package/dist/tunnel/utils.d.ts +18 -0
  77. package/dist/tunnel/utils.d.ts.map +1 -0
  78. package/dist/tunnel/utils.js +57 -0
  79. package/dist/tunnel/utils.js.map +1 -0
  80. package/dist/types.d.ts +52 -0
  81. package/dist/types.d.ts.map +1 -0
  82. package/dist/types.js +4 -0
  83. package/dist/types.js.map +1 -0
  84. package/dist/utils/anthropic-to-openai-converter.d.ts +103 -0
  85. package/dist/utils/anthropic-to-openai-converter.d.ts.map +1 -0
  86. package/dist/utils/anthropic-to-openai-converter.js +376 -0
  87. package/dist/utils/anthropic-to-openai-converter.js.map +1 -0
  88. package/dist/utils/chat-to-responses.d.ts +59 -0
  89. package/dist/utils/chat-to-responses.d.ts.map +1 -0
  90. package/dist/utils/chat-to-responses.js +395 -0
  91. package/dist/utils/chat-to-responses.js.map +1 -0
  92. package/dist/utils/chatgpt-instructions.d.ts +3 -0
  93. package/dist/utils/chatgpt-instructions.d.ts.map +1 -0
  94. package/dist/utils/chatgpt-instructions.js +12 -0
  95. package/dist/utils/chatgpt-instructions.js.map +1 -0
  96. package/dist/utils/cli-args.d.ts +3 -0
  97. package/dist/utils/cli-args.d.ts.map +1 -0
  98. package/dist/utils/cli-args.js +10 -0
  99. package/dist/utils/cli-args.js.map +1 -0
  100. package/dist/utils/cors-bypass.d.ts +4 -0
  101. package/dist/utils/cors-bypass.d.ts.map +1 -0
  102. package/dist/utils/cors-bypass.js +30 -0
  103. package/dist/utils/cors-bypass.js.map +1 -0
  104. package/dist/utils/cursor-byok-bypass.d.ts +37 -0
  105. package/dist/utils/cursor-byok-bypass.d.ts.map +1 -0
  106. package/dist/utils/cursor-byok-bypass.js +53 -0
  107. package/dist/utils/cursor-byok-bypass.js.map +1 -0
  108. package/dist/utils/logger.d.ts +19 -0
  109. package/dist/utils/logger.d.ts.map +1 -0
  110. package/dist/utils/logger.js +192 -0
  111. package/dist/utils/logger.js.map +1 -0
  112. package/dist/utils/port.d.ts +27 -0
  113. package/dist/utils/port.d.ts.map +1 -0
  114. package/dist/utils/port.js +78 -0
  115. package/dist/utils/port.js.map +1 -0
  116. package/dist/utils/setup-instructions.d.ts +10 -0
  117. package/dist/utils/setup-instructions.d.ts.map +1 -0
  118. package/dist/utils/setup-instructions.js +49 -0
  119. package/dist/utils/setup-instructions.js.map +1 -0
  120. package/env.example +25 -0
  121. package/index.html +992 -0
  122. package/package.json +57 -0
  123. package/public/.nojekyll +0 -0
  124. package/public/assets/chat.png +0 -0
  125. package/public/assets/demo.gif +0 -0
  126. package/public/assets/demo.mp4 +0 -0
  127. package/public/assets/setup.png +0 -0
  128. package/public/assets/ui.png +0 -0
  129. package/public/index.html +292 -0
  130. package/release-please-config.json +10 -0
  131. package/src/auth/provider.ts +412 -0
  132. package/src/cli.ts +97 -0
  133. package/src/mcp/proxy.ts +64 -0
  134. package/src/mcp.ts +56 -0
  135. package/src/oauth/authorize.ts +270 -0
  136. package/src/oauth/crypto.ts +198 -0
  137. package/src/oauth/dcr.ts +129 -0
  138. package/src/oauth/metadata.ts +40 -0
  139. package/src/oauth/token.ts +173 -0
  140. package/src/routes/auth.ts +149 -0
  141. package/src/routes/chat.ts +983 -0
  142. package/src/routes/oauth.ts +220 -0
  143. package/src/routes/tunnels.ts +45 -0
  144. package/src/server.ts +204 -0
  145. package/src/tunnel/providers/cloudflare.ts +50 -0
  146. package/src/tunnel/providers/index.ts +7 -0
  147. package/src/tunnel/providers/ngrok.ts +56 -0
  148. package/src/tunnel/providers/tailscale.ts +50 -0
  149. package/src/tunnel/registry.ts +96 -0
  150. package/src/tunnel/types.ts +32 -0
  151. package/src/tunnel/utils.ts +59 -0
  152. package/src/types.ts +55 -0
  153. package/src/utils/anthropic-to-openai-converter.ts +578 -0
  154. package/src/utils/chat-to-responses.ts +512 -0
  155. package/src/utils/chatgpt-instructions.ts +7 -0
  156. package/src/utils/cli-args.ts +8 -0
  157. package/src/utils/cors-bypass.ts +39 -0
  158. package/src/utils/cursor-byok-bypass.ts +56 -0
  159. package/src/utils/logger.ts +174 -0
  160. package/src/utils/port.ts +99 -0
  161. package/src/utils/setup-instructions.ts +59 -0
  162. package/tsconfig.json +22 -0
  163. package/vercel.json +20 -0
@@ -0,0 +1,412 @@
1
+ import crypto from 'crypto'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+
5
+ // ============================================================================
6
+ // Types
7
+ // ============================================================================
8
+
9
+ export interface AuthSession {
10
+ authUrl: string
11
+ userCode?: string // For device flow (OpenAI)
12
+ interval?: number // Polling interval in seconds (OpenAI)
13
+ sessionId: string
14
+ expiresAt: number
15
+ }
16
+
17
+ export interface TokenResult {
18
+ accessToken: string
19
+ refreshToken?: string
20
+ expiresIn?: number
21
+ accountId?: string
22
+ email?: string
23
+ }
24
+
25
+ export interface AuthProvider {
26
+ id: string
27
+ name: string
28
+ startAuth(): Promise<AuthSession>
29
+ completeAuth(input: string, sessionId: string): Promise<TokenResult>
30
+ }
31
+
32
+ // ============================================================================
33
+ // Claude Provider - OAuth + PKCE
34
+ // ============================================================================
35
+
36
+ const CLAUDE_CLIENT_ID =
37
+ process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
38
+ const CLAUDE_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback'
39
+ const CLAUDE_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'
40
+
41
+ export class ClaudeProvider implements AuthProvider {
42
+ id = 'claude'
43
+ name = 'Claude'
44
+
45
+ async startAuth(): Promise<AuthSession> {
46
+ const verifier = crypto.randomBytes(32).toString('base64url')
47
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
48
+
49
+ const authUrl = new URL('https://claude.ai/oauth/authorize')
50
+ authUrl.searchParams.set('code', 'true')
51
+ authUrl.searchParams.set('client_id', CLAUDE_CLIENT_ID)
52
+ authUrl.searchParams.set('response_type', 'code')
53
+ authUrl.searchParams.set('redirect_uri', CLAUDE_REDIRECT_URI)
54
+ authUrl.searchParams.set('scope', 'org:create_api_key user:profile user:inference')
55
+ authUrl.searchParams.set('code_challenge', challenge)
56
+ authUrl.searchParams.set('code_challenge_method', 'S256')
57
+ authUrl.searchParams.set('state', verifier)
58
+
59
+ return {
60
+ authUrl: authUrl.toString(),
61
+ sessionId: verifier,
62
+ expiresAt: Date.now() + 15 * 60 * 1000,
63
+ }
64
+ }
65
+
66
+ async completeAuth(codeInput: string, sessionId: string): Promise<TokenResult> {
67
+ // Parse CODE#STATE format
68
+ const parts = codeInput.trim().split('#')
69
+ const code = parts[0]
70
+ const state = parts[1] || sessionId
71
+
72
+ const response = await fetch(CLAUDE_TOKEN_URL, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({
76
+ code,
77
+ state,
78
+ grant_type: 'authorization_code',
79
+ client_id: CLAUDE_CLIENT_ID,
80
+ redirect_uri: CLAUDE_REDIRECT_URI,
81
+ code_verifier: sessionId,
82
+ }),
83
+ })
84
+
85
+ if (!response.ok) {
86
+ const error = await response.text()
87
+ throw new Error(`Claude token exchange failed: ${error}`)
88
+ }
89
+
90
+ const data = (await response.json()) as {
91
+ access_token: string
92
+ refresh_token: string
93
+ expires_in: number
94
+ account?: {
95
+ uuid: string
96
+ email_address: string
97
+ }
98
+ organization?: {
99
+ uuid: string
100
+ name: string
101
+ }
102
+ }
103
+
104
+ return {
105
+ accessToken: data.access_token,
106
+ refreshToken: data.refresh_token,
107
+ expiresIn: data.expires_in,
108
+ email: data.account?.email_address,
109
+ }
110
+ }
111
+ }
112
+
113
+ export async function refreshClaudeToken(refreshToken: string): Promise<TokenResult> {
114
+ const response = await fetch(CLAUDE_TOKEN_URL, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({
118
+ grant_type: 'refresh_token',
119
+ client_id: CLAUDE_CLIENT_ID,
120
+ refresh_token: refreshToken,
121
+ }),
122
+ })
123
+
124
+ if (!response.ok) {
125
+ const error = await response.text()
126
+ throw new Error(`Claude token refresh failed: ${error}`)
127
+ }
128
+
129
+ const data = (await response.json()) as {
130
+ access_token: string
131
+ refresh_token?: string
132
+ expires_in?: number
133
+ }
134
+
135
+ return {
136
+ accessToken: data.access_token,
137
+ refreshToken: data.refresh_token,
138
+ expiresIn: data.expires_in,
139
+ }
140
+ }
141
+
142
+ // ============================================================================
143
+ // OpenAI Provider - Device Code Flow
144
+ // ============================================================================
145
+
146
+ const OPENAI_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
147
+ const OPENAI_AUTH_BASE_URL = 'https://auth.openai.com'
148
+ const OPENAI_DEVICE_AUTH_BASE_URL = `${OPENAI_AUTH_BASE_URL}/api/accounts`
149
+ const OPENAI_DEVICE_CODE_URL = `${OPENAI_DEVICE_AUTH_BASE_URL}/deviceauth/usercode`
150
+ const OPENAI_DEVICE_POLL_URL = `${OPENAI_DEVICE_AUTH_BASE_URL}/deviceauth/token`
151
+ const OPENAI_DEVICE_REDIRECT_URI = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`
152
+ const OPENAI_TOKEN_URL = `${OPENAI_AUTH_BASE_URL}/oauth/token`
153
+ const OPENAI_USER_AUTH_URL = `${OPENAI_AUTH_BASE_URL}/codex/device`
154
+ const OPENAI_OAUTH_SCOPE = process.env.OPENAI_OAUTH_SCOPE || 'model.request'
155
+ const OPENAI_DEVICE_HEADERS = {
156
+ 'Content-Type': 'application/json',
157
+ 'User-Agent': 'reqwest/0.12.24',
158
+ }
159
+ const OPENAI_TOKEN_HEADERS = {
160
+ 'Content-Type': 'application/x-www-form-urlencoded',
161
+ 'User-Agent': 'reqwest/0.12.24',
162
+ }
163
+
164
+ function parseJwtPayload(token: string): Record<string, unknown> | null {
165
+ const parts = token.split('.')
166
+ if (parts.length < 2) return null
167
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
168
+ const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)
169
+ try {
170
+ const decoded = Buffer.from(padded, 'base64').toString('utf8')
171
+ return JSON.parse(decoded) as Record<string, unknown>
172
+ } catch {
173
+ return null
174
+ }
175
+ }
176
+
177
+ function extractChatGptAccountId(idToken: string | undefined, accessToken?: string): string | undefined {
178
+ const tokens = [idToken, accessToken].filter((value): value is string => Boolean(value))
179
+ for (const token of tokens) {
180
+ const payload = parseJwtPayload(token)
181
+ if (!payload) continue
182
+ const direct = payload?.chatgpt_account_id
183
+ if (typeof direct === 'string' && direct.trim()) return direct
184
+ const authClaim = payload?.['https://api.openai.com/auth']
185
+ const nested = (authClaim as Record<string, unknown> | undefined)?.chatgpt_account_id
186
+ if (typeof nested === 'string' && nested.trim()) return nested
187
+ }
188
+ return undefined
189
+ }
190
+
191
+ function extractEmailFromIdToken(idToken: string | undefined): string | undefined {
192
+ if (!idToken) return undefined
193
+ const payload = parseJwtPayload(idToken)
194
+ if (!payload) return undefined
195
+ const email = payload?.email
196
+ if (typeof email === 'string' && email.trim()) return email
197
+ return undefined
198
+ }
199
+
200
+ const execFileAsync = promisify(execFile)
201
+
202
+ type HttpResult = {
203
+ status: number
204
+ ok: boolean
205
+ text: string
206
+ }
207
+
208
+ function parseCurlResult(stdout: string): HttpResult {
209
+ const marker = '\n__STATUS__:'
210
+ const idx = stdout.lastIndexOf(marker)
211
+ if (idx === -1) {
212
+ return { status: 0, ok: false, text: stdout }
213
+ }
214
+ const body = stdout.slice(0, idx)
215
+ const statusText = stdout.slice(idx + marker.length).trim()
216
+ const status = parseInt(statusText, 10)
217
+ return { status, ok: status >= 200 && status < 300, text: body }
218
+ }
219
+
220
+ async function curlPost(url: string, contentType: string, body: string): Promise<HttpResult> {
221
+ const args = [
222
+ '-sS',
223
+ '-X',
224
+ 'POST',
225
+ url,
226
+ '-H',
227
+ `Content-Type: ${contentType}`,
228
+ '-H',
229
+ 'User-Agent: reqwest/0.12.24',
230
+ '-d',
231
+ body,
232
+ '-w',
233
+ '\n__STATUS__:%{http_code}',
234
+ ]
235
+ const { stdout } = await execFileAsync('curl', args, { maxBuffer: 5 * 1024 * 1024 })
236
+ return parseCurlResult(stdout)
237
+ }
238
+
239
+ async function postWithFallback(
240
+ url: string,
241
+ headers: Record<string, string>,
242
+ body: string,
243
+ contentType: string,
244
+ ): Promise<HttpResult> {
245
+ const response = await fetch(url, { method: 'POST', headers, body })
246
+ const text = await response.text()
247
+ if (response.ok) {
248
+ return { status: response.status, ok: true, text }
249
+ }
250
+ if (text.includes('cdn-cgi/challenge-platform')) {
251
+ try {
252
+ return await curlPost(url, contentType, body)
253
+ } catch {
254
+ return { status: response.status, ok: false, text }
255
+ }
256
+ }
257
+ return { status: response.status, ok: false, text }
258
+ }
259
+
260
+ function normalizeOpenAIError(status: number, body: string) {
261
+ const trimmed = body.trim()
262
+ if (!trimmed) return `HTTP ${status}`
263
+ if (trimmed.startsWith('<') || trimmed.includes('cdn-cgi/challenge-platform')) {
264
+ return `OpenAI auth endpoint returned a Cloudflare challenge (HTTP ${status}).`
265
+ }
266
+ return trimmed
267
+ }
268
+
269
+ interface DeviceCodeResponse {
270
+ device_auth_id: string
271
+ user_code: string
272
+ interval: number | string
273
+ }
274
+
275
+ interface DevicePollResponse {
276
+ authorization_code: string
277
+ code_challenge: string
278
+ code_verifier: string
279
+ }
280
+
281
+ // Store active device sessions for polling
282
+ const deviceSessions = new Map<
283
+ string,
284
+ {
285
+ deviceAuthId: string
286
+ userCode: string
287
+ interval: number
288
+ createdAt: number
289
+ }
290
+ >()
291
+
292
+ export class OpenAIProvider implements AuthProvider {
293
+ id = 'openai'
294
+ name = 'ChatGPT'
295
+
296
+ getAuthUrl(): string {
297
+ return OPENAI_USER_AUTH_URL
298
+ }
299
+
300
+ async startAuth(): Promise<AuthSession> {
301
+ const body = JSON.stringify({ client_id: OPENAI_CLIENT_ID, scope: OPENAI_OAUTH_SCOPE })
302
+ const response = await postWithFallback(
303
+ OPENAI_DEVICE_CODE_URL,
304
+ OPENAI_DEVICE_HEADERS,
305
+ body,
306
+ 'application/json',
307
+ )
308
+
309
+ if (!response.ok) {
310
+ throw new Error(`Failed to get device code: ${normalizeOpenAIError(response.status, response.text)}`)
311
+ }
312
+
313
+ const data = JSON.parse(response.text) as DeviceCodeResponse
314
+ const sessionId = data.device_auth_id
315
+ const interval = typeof data.interval === 'string' ? parseInt(data.interval, 10) : data.interval
316
+ const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 5
317
+
318
+ // Store session for polling
319
+ deviceSessions.set(sessionId, {
320
+ deviceAuthId: data.device_auth_id,
321
+ userCode: data.user_code,
322
+ interval: safeInterval,
323
+ createdAt: Date.now(),
324
+ })
325
+
326
+ return {
327
+ authUrl: OPENAI_USER_AUTH_URL,
328
+ userCode: data.user_code,
329
+ interval: safeInterval,
330
+ sessionId,
331
+ expiresAt: Date.now() + 15 * 60 * 1000,
332
+ }
333
+ }
334
+
335
+ async completeAuth(_input: string, sessionId: string): Promise<TokenResult> {
336
+ const session = deviceSessions.get(sessionId)
337
+ if (!session) {
338
+ throw new Error('Session not found or expired')
339
+ }
340
+
341
+ // Poll for authorization
342
+ const pollBody = JSON.stringify({
343
+ device_auth_id: session.deviceAuthId,
344
+ user_code: session.userCode,
345
+ })
346
+ const pollResponse = await postWithFallback(
347
+ OPENAI_DEVICE_POLL_URL,
348
+ OPENAI_DEVICE_HEADERS,
349
+ pollBody,
350
+ 'application/json',
351
+ )
352
+
353
+ if (!pollResponse.ok) {
354
+ if (pollResponse.status === 403 || pollResponse.status === 404 || pollResponse.status === 429) {
355
+ throw new Error('PENDING') // User hasn't authorized yet
356
+ }
357
+ throw new Error(`Poll failed: ${normalizeOpenAIError(pollResponse.status, pollResponse.text)}`)
358
+ }
359
+
360
+ const pollData = JSON.parse(pollResponse.text) as DevicePollResponse
361
+
362
+ // Exchange authorization code for tokens
363
+ const tokenBody = new URLSearchParams({
364
+ grant_type: 'authorization_code',
365
+ client_id: OPENAI_CLIENT_ID,
366
+ code: pollData.authorization_code,
367
+ code_verifier: pollData.code_verifier,
368
+ redirect_uri: OPENAI_DEVICE_REDIRECT_URI,
369
+ scope: OPENAI_OAUTH_SCOPE,
370
+ }).toString()
371
+ const tokenResponse = await postWithFallback(
372
+ OPENAI_TOKEN_URL,
373
+ OPENAI_TOKEN_HEADERS,
374
+ tokenBody,
375
+ 'application/x-www-form-urlencoded',
376
+ )
377
+
378
+ if (!tokenResponse.ok) {
379
+ throw new Error(`Token exchange failed: ${normalizeOpenAIError(tokenResponse.status, tokenResponse.text)}`)
380
+ }
381
+
382
+ const tokenData = JSON.parse(tokenResponse.text) as {
383
+ access_token: string
384
+ refresh_token: string
385
+ id_token: string
386
+ expires_in: number
387
+ }
388
+
389
+ const accountId = extractChatGptAccountId(tokenData.id_token, tokenData.access_token)
390
+ const email = extractEmailFromIdToken(tokenData.id_token)
391
+
392
+ // Clean up session
393
+ deviceSessions.delete(sessionId)
394
+
395
+ return {
396
+ accessToken: tokenData.access_token,
397
+ refreshToken: tokenData.refresh_token,
398
+ expiresIn: tokenData.expires_in,
399
+ accountId,
400
+ email,
401
+ }
402
+ }
403
+
404
+ // Get session info for polling UI
405
+ getSession(sessionId: string) {
406
+ return deviceSessions.get(sessionId)
407
+ }
408
+ }
409
+
410
+ // Export singleton instances
411
+ export const claudeProvider = new ClaudeProvider()
412
+ export const openaiProvider = new OpenAIProvider()
package/src/cli.ts ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sub Bridge CLI
4
+ *
5
+ * Main entry point that can run in different modes:
6
+ * - Default: Starts HTTP server + MCP server (original behavior)
7
+ * - --server-only: Just HTTP server
8
+ * - --mcp-only: Just MCP proxy (discovers/starts HTTP server)
9
+ *
10
+ * For development, use the separate entry points:
11
+ * - npm run dev:server - HTTP server with hot reload
12
+ * - npm run dev:mcp - MCP proxy (connects to existing server or starts inline)
13
+ */
14
+ import { program } from 'commander'
15
+ import chalk from 'chalk'
16
+ import { startServer, type ServerConfig } from './server'
17
+ import { findPort } from './utils/port'
18
+ import { startMcpServer } from './mcp/proxy'
19
+ import { addSharedOptions } from './utils/cli-args'
20
+
21
+ const log = (...args: Parameters<typeof console.error>) => console.error(...args)
22
+
23
+ async function main() {
24
+ const prog = program
25
+ .name('sub-bridge')
26
+ .description('MCP bridge for ChatGPT Pro, Claude Max, etc. in Cursor')
27
+
28
+ addSharedOptions(prog)
29
+ .option('--server-only', 'Run HTTP server only (no MCP)')
30
+ .option('--mcp-only', 'Run MCP proxy only (discovers existing server)')
31
+ .parse()
32
+
33
+ const opts = program.opts()
34
+
35
+ const config: ServerConfig = {
36
+ port: opts.port ? parseInt(opts.port, 10) : undefined,
37
+ tunnelUrl: opts.tunnel,
38
+ verbose: opts.verbose || false,
39
+ }
40
+
41
+ // MCP-only mode: just run the thin proxy
42
+ if (opts.mcpOnly) {
43
+ const discovery = await findPort(config.port)
44
+
45
+ if (discovery.hasServer) {
46
+ log(`[sub-bridge] Found existing HTTP server on port ${discovery.port}`)
47
+ await startMcpServer(discovery.port, log)
48
+ } else {
49
+ log(`[sub-bridge] No HTTP server found, starting inline on port ${discovery.port}`)
50
+ const server = await startServer({ ...config, port: discovery.port })
51
+ await startMcpServer(server.port, log)
52
+ }
53
+ return
54
+ }
55
+
56
+ // Server-only mode: just run the HTTP server
57
+ if (opts.serverOnly) {
58
+ await startServer(config)
59
+ log(chalk.dim(' Press Ctrl+C to stop'))
60
+ return
61
+ }
62
+
63
+ // Default mode: start HTTP server + MCP (original behavior)
64
+ const server = await startServer(config)
65
+
66
+ printSetupInstructions(server.publicUrl)
67
+
68
+ await startMcpServer(server.port, log)
69
+ }
70
+
71
+ function printSetupInstructions(publicUrl: string) {
72
+ log()
73
+ log(chalk.bold.yellow(' Setup in Cursor:'))
74
+ log(chalk.dim(' ─────────────────────────────────────'))
75
+ log()
76
+ log(chalk.dim(' 1. Command Palette (Cmd+Shift+P / Ctrl+Shift+P)'))
77
+ log(chalk.dim(' 2. Search "Cursor Settings" → Open'))
78
+ log(chalk.dim(' 3. Navigate: Models → API Keys (expand)'))
79
+ log(chalk.dim(' 4. Enable "OpenAI API Key" toggle'))
80
+ log(chalk.dim(' 5. Set API key with routing:'))
81
+ log(chalk.dim(' '), chalk.cyan('o3=opus-4.5,o3-mini=sonnet-4.5:sk-ant-xxx'))
82
+ log(chalk.dim(' 6. Enable "Override OpenAI Base URL" toggle'))
83
+ log(chalk.dim(' 7. Set Base URL:'), chalk.cyan.bold(`${publicUrl}/v1`))
84
+ log()
85
+ log(chalk.dim(' Getting API keys:'))
86
+ log(chalk.dim(' • Open'), chalk.cyan(publicUrl), chalk.dim('in your external browser and click Login buttons'))
87
+ log(chalk.dim(' • Or use Claude Code CLI:'), chalk.cyan('claude setup-token'))
88
+ log(chalk.dim(' • Or use Codex CLI:'), chalk.cyan('codex login'))
89
+ log()
90
+ log(chalk.dim(' Use MCP tool: "get_status" to get URL anytime'))
91
+ log()
92
+ }
93
+
94
+ main().catch((error) => {
95
+ log('[sub-bridge] Fatal error:', error)
96
+ process.exit(1)
97
+ })
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared MCP proxy functionality
3
+ */
4
+ import { buildStatusText } from '../utils/setup-instructions'
5
+
6
+ export interface McpToolResult {
7
+ [x: string]: unknown
8
+ content: Array<{ type: 'text'; text: string }>
9
+ }
10
+
11
+ /**
12
+ * Call a tool on the HTTP server
13
+ */
14
+ export async function callTool(port: number, name: string, args: Record<string, unknown> = {}): Promise<McpToolResult> {
15
+ const localUrl = `http://localhost:${port}`
16
+
17
+ try {
18
+ const response = await fetch(`${localUrl}/mcp/tools/${name}`, {
19
+ method: 'POST',
20
+ headers: { 'content-type': 'application/json' },
21
+ body: JSON.stringify({ arguments: args }),
22
+ })
23
+
24
+ if (!response.ok) {
25
+ const error = await response.text()
26
+ return {
27
+ content: [{ type: 'text' as const, text: `Error calling tool ${name}: ${error}` }],
28
+ }
29
+ }
30
+
31
+ return response.json() as Promise<McpToolResult>
32
+ } catch {
33
+ // Server not reachable - return helpful setup instructions
34
+ return {
35
+ content: [{ type: 'text' as const, text: buildStatusText({ mode: 'proxy', baseUrl: localUrl }) }],
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Start MCP server and register tools that forward to HTTP server
42
+ */
43
+ export async function startMcpServer(serverPort: number, log: (...args: any[]) => void) {
44
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
45
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js')
46
+
47
+ const server = new McpServer({
48
+ name: 'Sub Bridge',
49
+ version: '2.1.0',
50
+ })
51
+
52
+ // Register tools that forward to HTTP server
53
+ server.tool(
54
+ 'get_status',
55
+ 'Get Sub Bridge status and login URL for Claude/ChatGPT authentication',
56
+ {},
57
+ async () => callTool(serverPort, 'get_status'),
58
+ )
59
+
60
+ // Start MCP transport
61
+ const transport = new StdioServerTransport()
62
+ await server.connect(transport)
63
+ log('[mcp] MCP server started, connected to HTTP server on port', serverPort)
64
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin MCP Proxy Entry Point
4
+ *
5
+ * This is the entry point for Cursor's MCP integration.
6
+ * It discovers or starts an HTTP server and forwards tool calls to it.
7
+ *
8
+ * Dev mode: Developer runs HTTP server separately with `tsx watch src/server.ts`
9
+ * Production: MCP proxy starts HTTP server inline if not found
10
+ */
11
+ import { program } from 'commander'
12
+ import { findPort } from './utils/port'
13
+ import { startServer, type ServerConfig } from './server'
14
+ import { startMcpServer } from './mcp/proxy'
15
+ import { addSharedOptions } from './utils/cli-args'
16
+
17
+ const log = (...args: Parameters<typeof console.error>) => console.error(...args)
18
+
19
+ async function main() {
20
+ // Parse CLI arguments
21
+ addSharedOptions(program).parse()
22
+ const opts = program.opts()
23
+
24
+ const cliPort = opts.port ? parseInt(opts.port, 10) : undefined
25
+ const tunnelUrl = opts.tunnel || process.env.TUNNEL_URL
26
+ const verbose = opts.verbose || process.env.VERBOSE === 'true'
27
+
28
+ // Discover existing server or find a free port
29
+ const discovery = await findPort(cliPort)
30
+
31
+ let serverPort = discovery.port
32
+
33
+ if (discovery.hasServer) {
34
+ log(`[mcp] Found existing HTTP server on port ${serverPort}`)
35
+ } else {
36
+ log(`[mcp] No HTTP server found, starting inline on port ${serverPort}`)
37
+
38
+ // Start HTTP server inline (production mode)
39
+ const config: ServerConfig = {
40
+ port: serverPort,
41
+ tunnelUrl,
42
+ verbose,
43
+ }
44
+
45
+ const server = await startServer(config)
46
+ serverPort = server.port
47
+ log(`[mcp] HTTP server started on port ${serverPort}`)
48
+ }
49
+
50
+ await startMcpServer(serverPort, log)
51
+ }
52
+
53
+ main().catch((error) => {
54
+ log('[mcp] Fatal error:', error)
55
+ process.exit(1)
56
+ })