sub-bridge 1.0.0 → 1.0.3

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 (69) hide show
  1. package/.github/workflows/npm-publish.yml +3 -2
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/dist/auth/provider.d.ts +1 -0
  5. package/dist/auth/provider.d.ts.map +1 -1
  6. package/dist/auth/provider.js +22 -0
  7. package/dist/auth/provider.js.map +1 -1
  8. package/dist/cli.js +0 -14
  9. package/dist/cli.js.map +1 -1
  10. package/dist/oauth/authorize.d.ts +73 -0
  11. package/dist/oauth/authorize.d.ts.map +1 -0
  12. package/dist/oauth/authorize.js +197 -0
  13. package/dist/oauth/authorize.js.map +1 -0
  14. package/dist/oauth/crypto.d.ts +58 -0
  15. package/dist/oauth/crypto.d.ts.map +1 -0
  16. package/dist/oauth/crypto.js +170 -0
  17. package/dist/oauth/crypto.js.map +1 -0
  18. package/dist/oauth/dcr.d.ts +44 -0
  19. package/dist/oauth/dcr.d.ts.map +1 -0
  20. package/dist/oauth/dcr.js +84 -0
  21. package/dist/oauth/dcr.js.map +1 -0
  22. package/dist/oauth/metadata.d.ts +23 -0
  23. package/dist/oauth/metadata.d.ts.map +1 -0
  24. package/dist/oauth/metadata.js +29 -0
  25. package/dist/oauth/metadata.js.map +1 -0
  26. package/dist/oauth/token.d.ts +29 -0
  27. package/dist/oauth/token.d.ts.map +1 -0
  28. package/dist/oauth/token.js +117 -0
  29. package/dist/oauth/token.js.map +1 -0
  30. package/dist/routes/chat.d.ts.map +1 -1
  31. package/dist/routes/chat.js +128 -15
  32. package/dist/routes/chat.js.map +1 -1
  33. package/dist/routes/oauth.d.ts +13 -0
  34. package/dist/routes/oauth.d.ts.map +1 -0
  35. package/dist/routes/oauth.js +174 -0
  36. package/dist/routes/oauth.js.map +1 -0
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +14 -6
  39. package/dist/server.js.map +1 -1
  40. package/dist/tunnel/providers/cloudflare.d.ts.map +1 -1
  41. package/dist/tunnel/providers/cloudflare.js +19 -5
  42. package/dist/tunnel/providers/cloudflare.js.map +1 -1
  43. package/dist/tunnel/registry.d.ts.map +1 -1
  44. package/dist/tunnel/registry.js +75 -9
  45. package/dist/tunnel/registry.js.map +1 -1
  46. package/dist/utils/cloudflared-config.d.ts +2 -0
  47. package/dist/utils/cloudflared-config.d.ts.map +1 -0
  48. package/dist/utils/cloudflared-config.js +116 -0
  49. package/dist/utils/cloudflared-config.js.map +1 -0
  50. package/dist/utils/setup-instructions.d.ts.map +1 -1
  51. package/dist/utils/setup-instructions.js +6 -9
  52. package/dist/utils/setup-instructions.js.map +1 -1
  53. package/index.html +268 -281
  54. package/package.json +6 -2
  55. package/src/cli.ts +0 -14
  56. package/src/routes/chat.ts +3 -0
  57. package/src/server.ts +10 -6
  58. package/src/tunnel/providers/cloudflare.ts +18 -5
  59. package/src/tunnel/registry.ts +79 -8
  60. package/src/utils/cloudflared-config.ts +121 -0
  61. package/src/utils/setup-instructions.ts +6 -9
  62. package/dist/auth/oauth-flow.d.ts +0 -24
  63. package/dist/auth/oauth-flow.d.ts.map +0 -1
  64. package/dist/auth/oauth-flow.js +0 -184
  65. package/dist/auth/oauth-flow.js.map +0 -1
  66. package/dist/auth/oauth-manager.d.ts +0 -13
  67. package/dist/auth/oauth-manager.d.ts.map +0 -1
  68. package/dist/auth/oauth-manager.js +0 -25
  69. package/dist/auth/oauth-manager.js.map +0 -1
package/index.html CHANGED
@@ -67,68 +67,68 @@
67
67
  <div class="flex flex-col gap-3">
68
68
 
69
69
  <!-- Claude Row -->
70
- <div class="flex items-center gap-3">
71
- <!-- Button -->
72
- <template x-if="!claude.showInput">
73
- <a
74
- @click.prevent="claude.showInput = true; window.open('/auth/claude', '_blank')"
75
- href="/auth/claude"
76
- class="group inline-flex items-center justify-center gap-2 rounded-lg bg-[#d97757] px-3 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#c4674b] cursor-pointer"
77
- >
78
- <svg class="h-4 w-4 text-white" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude"></use></svg>
79
- <span>Login with Claude</span>
80
- </a>
81
- </template>
82
- <template x-if="claude.showInput">
83
- <button
84
- @click="submitClaudeCode()"
85
- :disabled="claude.loading || !claude.code.includes('#')"
86
- class="group inline-flex items-center justify-center gap-2 rounded-lg bg-[#d97757] px-3 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#c4674b] disabled:opacity-50 shrink-0"
87
- >
88
- <svg class="h-4 w-4 text-white" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude"></use></svg>
89
- <span x-text="claude.loading ? 'Connecting...' : 'Paste Code'"></span>
90
- </button>
91
- </template>
92
- <!-- Input (shown after first click) -->
93
- <template x-if="claude.showInput">
94
- <input
95
- type="text"
96
- x-model="claude.code"
97
- @input="claudeCodeInput()"
98
- placeholder="CODE#STATE"
99
- class="flex-1 rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-600 focus:outline-none font-mono"
100
- :disabled="claude.loading"
101
- x-init="$nextTick(() => $el.focus())"
102
- >
103
- </template>
104
- <!-- Spacer -->
105
- <div class="flex-1" x-show="!claude.showInput"></div>
106
- <!-- Status Indicator -->
107
- <div class="flex items-center gap-2 shrink-0">
108
- <span class="h-2 w-2 rounded-full" :class="claude.connected ? 'bg-emerald-400' : 'bg-neutral-600'"></span>
109
- <span class="text-xs" :class="claude.connected ? 'text-emerald-400' : 'text-neutral-500'" x-text="claude.connected ? 'Connected' : 'Not connected'"></span>
110
- <!-- Account Switcher (show when multiple accounts) -->
111
- <template x-if="Object.keys(claude.accounts).length > 1">
112
- <select
113
- x-model="claude.activeAccount"
114
- @change="localStorage.setItem('claude_active_account', claude.activeAccount)"
115
- class="ml-2 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-200 focus:border-neutral-600 focus:outline-none"
70
+ <div class="flex flex-col gap-1">
71
+ <div class="flex items-center gap-3 flex-wrap">
72
+ <!-- Button -->
73
+ <template x-if="!claude.showInput">
74
+ <a
75
+ @click.prevent="claude.showInput = true; window.open('/auth/claude', '_blank')"
76
+ href="/auth/claude"
77
+ class="group inline-flex items-center justify-center gap-2 rounded-lg bg-[#d97757] px-3 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#c4674b] cursor-pointer"
116
78
  >
117
- <template x-for="email in Object.keys(claude.accounts)" :key="email">
118
- <option :value="email" x-text="claude.accounts[email].displayName || email"></option>
119
- </template>
120
- </select>
79
+ <svg class="h-4 w-4 text-white" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude"></use></svg>
80
+ <span>Login with Claude</span>
81
+ </a>
121
82
  </template>
122
- <template x-if="claude.connected">
83
+ <template x-if="claude.showInput">
123
84
  <button
124
- @click="disconnectClaude()"
125
- class="ml-1 text-xs text-neutral-400 hover:text-red-400 transition"
126
- title="Disconnect and clear token"
85
+ disabled
86
+ class="group inline-flex items-center justify-center gap-2 rounded-lg bg-[#d97757] px-3 py-2 text-sm font-semibold text-white shadow-sm transition disabled:opacity-50 shrink-0"
127
87
  >
128
- <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
88
+ <svg class="h-4 w-4 text-white" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude"></use></svg>
89
+ <span>Login with Claude</span>
129
90
  </button>
130
91
  </template>
92
+ <!-- Input (shown after first click) -->
93
+ <template x-if="claude.showInput">
94
+ <div class="flex items-center gap-2 flex-1">
95
+ <span class="text-xs text-neutral-400 shrink-0">Enter code</span>
96
+ <input
97
+ type="text"
98
+ x-model="claude.code"
99
+ @input="claudeCodeInput()"
100
+ placeholder="CODE#STATE"
101
+ class="flex-1 rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-600 focus:outline-none font-mono"
102
+ :disabled="claude.loading"
103
+ x-init="$nextTick(() => $el.focus())"
104
+ >
105
+ <template x-if="claude.loading">
106
+ <span class="text-xs text-neutral-400 shrink-0">Checking...</span>
107
+ </template>
108
+ </div>
109
+ </template>
110
+ </div>
111
+ <!-- Status Indicator -->
112
+ <div class="flex items-center gap-2 text-xs">
113
+ <span class="h-2 w-2 rounded-full" :class="claude.connected ? 'bg-emerald-400' : 'bg-neutral-600'"></span>
114
+ <span class="text-xs" :class="claude.connected ? 'text-emerald-400' : 'text-neutral-500'" x-text="claude.connected ? 'Connected' : 'Not connected'"></span>
131
115
  </div>
116
+ <template x-if="Object.keys(claude.accounts).length">
117
+ <div class="mt-1 flex flex-col gap-1">
118
+ <template x-for="email in Object.keys(claude.accounts)" :key="'claude-account-' + email">
119
+ <div class="flex items-center gap-2 text-xs">
120
+ <span class="text-neutral-300 font-mono" x-text="claude.accounts[email].displayName || email"></span>
121
+ <button
122
+ @click="disconnectClaude(email)"
123
+ class="text-xs text-neutral-400 hover:text-red-400 transition"
124
+ title="Disconnect account"
125
+ >
126
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
127
+ </button>
128
+ </div>
129
+ </template>
130
+ </div>
131
+ </template>
132
132
  </div>
133
133
  <!-- Claude Error -->
134
134
  <template x-if="claude.error">
@@ -136,56 +136,58 @@
136
136
  </template>
137
137
 
138
138
  <!-- ChatGPT Row -->
139
- <div class="flex items-center gap-3">
140
- <!-- Button -->
141
- <button
142
- @click="if(!chatgpt.showInput) { startChatGPT(); window.open('https://auth.openai.com/codex/device', '_blank'); } chatgpt.showInput = true"
143
- :disabled="chatgpt.polling"
139
+ <div class="flex flex-col gap-1">
140
+ <div class="flex items-center gap-3 flex-wrap">
141
+ <!-- Button -->
142
+ <button
143
+ @click="if(!chatgpt.showInput) { startChatGPT(); window.open('https://auth.openai.com/codex/device', '_blank'); } chatgpt.showInput = true"
144
+ :disabled="chatgpt.polling || chatgpt.showInput"
144
145
  class="group inline-flex items-center justify-center gap-2 rounded-lg bg-[#10a37f] px-3 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#0d8a6a] disabled:opacity-50 shrink-0"
145
146
  >
146
147
  <svg class="h-4 w-4 text-white" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-openai"></use></svg>
147
- <span x-text="chatgpt.polling ? 'Checking...' : (chatgpt.showInput ? 'Enter Device ID' : 'Login with ChatGPT')"></span>
148
+ <span>Login with ChatGPT</span>
148
149
  </button>
149
150
  <!-- Login link + Input (shown after first click) -->
150
151
  <template x-if="chatgpt.showInput">
151
152
  <div class="flex items-center gap-2">
153
+ <span class="text-xs text-neutral-400 shrink-0">Enter device id</span>
152
154
  <input
153
155
  type="text"
154
156
  :value="chatgpt.deviceId"
155
- readonly
156
- @focus="$el.select()"
157
- class="w-32 rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 font-mono text-center"
158
- >
159
- </div>
160
- </template>
161
- <!-- Spacer (always present to push status to right) -->
162
- <div class="flex-1"></div>
157
+ readonly
158
+ @focus="$el.select()"
159
+ class="w-32 rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 font-mono text-center"
160
+ >
161
+ <template x-if="chatgpt.polling">
162
+ <span class="text-xs text-neutral-400 shrink-0">Checking...</span>
163
+ </template>
164
+ </div>
165
+ </template>
166
+ </div>
163
167
  <!-- Status Indicator -->
164
- <div class="flex items-center gap-2 shrink-0">
168
+ <div class="flex items-center gap-2 text-xs">
165
169
  <span class="h-2 w-2 rounded-full" :class="chatgpt.connected ? 'bg-emerald-400' : 'bg-neutral-600'"></span>
166
170
  <span class="text-xs" :class="chatgpt.connected ? 'text-emerald-400' : 'text-neutral-500'" x-text="chatgpt.connected ? 'Connected' : 'Not connected'"></span>
167
- <!-- Account Switcher (show when multiple accounts) -->
168
- <template x-if="Object.keys(chatgpt.accounts).length > 1">
169
- <select
170
- x-model="chatgpt.activeAccount"
171
- @change="localStorage.setItem('chatgpt_active_account', chatgpt.activeAccount)"
172
- class="ml-2 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-200 focus:border-neutral-600 focus:outline-none"
173
- >
174
- <template x-for="accountId in Object.keys(chatgpt.accounts)" :key="accountId">
175
- <option :value="accountId" x-text="chatgpt.accounts[accountId].displayName || accountId"></option>
176
- </template>
177
- </select>
178
- </template>
179
- <template x-if="chatgpt.connected">
180
- <button
181
- @click="disconnectChatGPT()"
182
- class="ml-1 text-xs text-neutral-400 hover:text-red-400 transition"
183
- title="Disconnect and clear token"
184
- >
185
- <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
186
- </button>
187
- </template>
188
171
  </div>
172
+ <template x-if="Object.keys(chatgpt.accounts).length">
173
+ <div class="mt-1 flex flex-col gap-1">
174
+ <template x-for="accountId in Object.keys(chatgpt.accounts)" :key="'chatgpt-account-' + accountId">
175
+ <div class="flex items-center gap-2 text-xs">
176
+ <span
177
+ class="text-neutral-300 font-mono"
178
+ x-text="chatgpt.accounts[accountId].displayName && chatgpt.accounts[accountId].displayName !== accountId ? chatgpt.accounts[accountId].displayName + ' (' + accountId + ')' : accountId"
179
+ ></span>
180
+ <button
181
+ @click="disconnectChatGPT(accountId)"
182
+ class="text-xs text-neutral-400 hover:text-red-400 transition"
183
+ title="Disconnect account"
184
+ >
185
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
186
+ </button>
187
+ </div>
188
+ </template>
189
+ </div>
190
+ </template>
189
191
  </div>
190
192
  <!-- ChatGPT Error -->
191
193
  <template x-if="chatgpt.error">
@@ -216,30 +218,95 @@
216
218
  <div class="space-y-4" x-init="fetchTunnelProviders()">
217
219
  <section class="space-y-2">
218
220
  <h2 class="text-xs font-semibold uppercase tracking-wider text-neutral-400">OpenAI API Gateway</h2>
219
- <div class="rounded-xl border border-neutral-800 bg-neutral-900/70 shadow-sm flex flex-col gap-3">
220
- <div class="flex items-center gap-3 p-4 pb-0">
221
+ <div class="rounded-xl border border-neutral-800 bg-neutral-900/70 shadow-sm">
222
+ <div class="flex flex-col gap-4 p-4">
223
+ <div class="flex items-center justify-between">
224
+ <div class="flex items-center gap-2">
225
+ <span
226
+ class="h-2 w-2 rounded-full"
227
+ :class="tunnel.loading ? 'bg-neutral-500 animate-pulse' : (tunnel.status.active || hasExternalUrl ? 'bg-emerald-400' : 'bg-neutral-600')"
228
+ ></span>
229
+ <span
230
+ class="text-xs"
231
+ :class="tunnel.loading ? 'text-neutral-400' : (tunnel.status.active || hasExternalUrl ? 'text-emerald-400' : 'text-neutral-500')"
232
+ x-text="tunnel.loading ? 'Loading' : (tunnel.status.active ? 'Connected' : (hasExternalUrl ? 'External URL' : 'Inactive'))"
233
+ ></span>
234
+ </div>
235
+ <template x-if="tunnel.status.active">
236
+ <button
237
+ @click="stopTunnel()"
238
+ class="rounded-lg border border-red-500/60 px-3 py-1.5 text-xs font-semibold text-red-200 hover:bg-red-500/20"
239
+ >Stop Tunnel</button>
240
+ </template>
241
+ </div>
242
+
243
+ <div class="space-y-1">
244
+ <label
245
+ class="text-[10px] font-semibold uppercase tracking-wider text-neutral-500"
246
+ x-text="tunnelLabel"
247
+ ></label>
248
+ <input
249
+ type="text"
250
+ :value="hasTunnel ? effectivePublicUrl + '/v1' : tunnel.customDomain"
251
+ :readonly="hasTunnel"
252
+ @input="if (!hasTunnel) tunnel.customDomain = $event.target.value"
253
+ @focus="$el.select()"
254
+ :placeholder="hasTunnel ? '' : (tunnel.loading ? 'Loading providers...' : 'e.g. yourname.example.com')"
255
+ class="w-full rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 placeholder:text-neutral-600 focus:border-neutral-600 focus:outline-none font-mono"
256
+ >
257
+ </div>
258
+
259
+ <template x-if="!hasTunnel">
260
+ <div class="space-y-3">
261
+ <div class="flex flex-wrap items-center justify-between gap-3">
262
+ <div class="flex flex-wrap gap-2" x-show="!tunnel.loading && tunnel.providers.some(p => p.available)">
263
+ <template x-for="p in tunnel.providers.filter(p => p.available)" :key="p.id">
264
+ <button
265
+ @click="tunnel.selectedProvider = p.id"
266
+ :class="tunnel.selectedProvider === p.id ? 'bg-neutral-700 text-white border-neutral-600' : 'bg-neutral-950 text-neutral-400 border-neutral-800'"
267
+ class="inline-flex items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition"
268
+ x-text="p.name"
269
+ ></button>
270
+ </template>
271
+ </div>
272
+ <div class="flex flex-wrap gap-2" x-show="tunnel.loading">
273
+ <span class="h-7 w-20 rounded-lg border border-neutral-800 bg-neutral-900/60 animate-pulse"></span>
274
+ <span class="h-7 w-16 rounded-lg border border-neutral-800 bg-neutral-900/60 animate-pulse"></span>
275
+ <span class="h-7 w-12 rounded-lg border border-neutral-800 bg-neutral-900/60 animate-pulse"></span>
276
+ </div>
277
+ <template x-if="!tunnel.status.active && !hasExternalUrl">
278
+ <button
279
+ @click="startTunnel()"
280
+ :disabled="tunnel.starting || !tunnel.selectedProvider || !tunnel.providers.some(p => p.available)"
281
+ class="rounded-lg bg-neutral-100 px-3 py-2 text-xs font-semibold text-neutral-950 hover:bg-white disabled:opacity-50"
282
+ x-text="tunnel.starting ? 'Creating...' : 'Create Tunnel'"
283
+ x-show="!tunnel.loading"
284
+ ></button>
285
+ </template>
286
+ </div>
287
+ <p x-show="!tunnel.providers.some(p => p.available) && !tunnel.loading" class="text-xs text-neutral-400">
288
+ No tunnel providers found (install cloudflared, ngrok, or tailscale).
289
+ </p>
290
+ </div>
291
+ </template>
292
+
293
+ <p x-show="tunnel.error" class="text-xs text-red-400" x-text="tunnel.error"></p>
294
+ </div>
295
+
296
+ <div class="space-y-1 px-4 pb-4">
297
+ <label class="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">Generated API Key</label>
221
298
  <input
222
299
  type="text"
223
- :value="effectivePublicUrl + '/v1'"
300
+ :value="apiKey"
224
301
  readonly
225
302
  @focus="$el.select()"
303
+ placeholder="Connect to a provider first..."
226
304
  class="w-full rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 focus:border-neutral-600 focus:outline-none font-mono"
227
305
  >
228
- <label class="text-xs text-neutral-500 shrink-0 w-28 text-right">OpenAI Base URL</label>
229
306
  </div>
230
- <div class="flex items-center gap-3 px-4 pb-0">
231
- <input
232
- type="text"
233
- :value="apiKey"
234
- readonly
235
- @focus="$el.select()"
236
- placeholder="Connect to a provider first..."
237
- class="w-full rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 focus:border-neutral-600 focus:outline-none font-mono"
238
- > <label class="text-xs text-neutral-500 shrink-0 w-28 text-right">Generated API Key</label>
239
- </div>
240
307
 
241
- <div class="flex items-center gap-3 p-4 border-t border-neutral-800">
242
- <template x-if="claude.connected || chatgpt.connected">
308
+ <template x-if="claude.connected || chatgpt.connected">
309
+ <div class="flex items-center gap-3 p-4 border-t border-neutral-800">
243
310
  <div class="flex items-start gap-3">
244
311
  <div class="flex flex-col gap-2 w-full">
245
312
  <template x-for="(route, i) in routes" :key="i">
@@ -258,7 +325,7 @@
258
325
  <template x-if="Object.keys(claude.accounts).length > 1">
259
326
  <select
260
327
  x-model="route.account"
261
- x-init="route.account = route.account || claude.activeAccount"
328
+ x-init="route.account = route.account || firstClaudeAccountId"
262
329
  class="rounded-lg border border-neutral-800 bg-neutral-950 px-2 py-2 text-sm text-neutral-100 focus:border-neutral-600 focus:outline-none"
263
330
  >
264
331
  <template x-for="email in Object.keys(claude.accounts)" :key="'route-account-' + email">
@@ -283,7 +350,7 @@
283
350
  </div>
284
351
  </template>
285
352
  <button
286
- @click="routes.push({ cursor: '', claude: '', account: claude.activeAccount || '' })"
353
+ @click="routes.push({ cursor: '', claude: '', account: firstClaudeAccountId || '' })"
287
354
  class="inline-flex items-center gap-1 text-sm text-neutral-400 hover:text-neutral-200 transition mt-1"
288
355
  >
289
356
  <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
@@ -292,8 +359,8 @@
292
359
  </div>
293
360
  <label class="text-xs text-neutral-500 shrink-0 w-28 text-right self-start">Routes</label>
294
361
  </div>
362
+ </div>
295
363
  </template>
296
- </div>
297
364
  </div>
298
365
  </section>
299
366
  <!-- Integrate Section -->
@@ -341,102 +408,18 @@
341
408
  <li class="flex flex-col gap-1">
342
409
  <div class="flex items-start gap-2">
343
410
  <span class="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-neutral-800 text-[11px] shrink-0">5</span>
344
- <div class="flex-1 space-y-2">
345
- <!-- Show URL when tunnel is active (dynamic or pre-configured) -->
346
- <template x-if="hasTunnel">
347
- <div class="relative w-full" style="margin-top: 0px !important;">
348
- <span class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs pr-1">Set Base URL:</span>
349
- <input
350
- type="text"
351
- :value="effectivePublicUrl + '/v1'"
352
- readonly
353
- @focus="$el.select()"
354
- class="rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-xs text-neutral-100 focus:border-neutral-600 focus:outline-none font-mono pl-[100px] w-full"
355
- >
356
- </div>
357
- </template>
358
- <!-- Show instructions when no tunnel -->
359
- <template x-if="!hasTunnel">
360
- <div>
361
- <span>Set Base URL: Expose port <code class="rounded bg-neutral-800/80 px-1.5 py-0.5 text-xs" x-text="port"></code> publicly or</span>
362
- <button
363
- type="button"
364
- @click="tunnel.showPanel = !tunnel.showPanel; if(tunnel.showPanel && !tunnel.providers.length) fetchTunnelProviders()"
365
- class="text-sky-400 hover:text-sky-300 hover:underline"
366
- >create a tunnel</button>
367
- </div>
368
- </template>
369
- <!-- Manage tunnel link when dynamic tunnel is active (not for pre-configured) -->
370
- <template x-if="tunnel.status.active">
371
- <button
372
- type="button"
373
- @click="tunnel.showPanel = !tunnel.showPanel"
374
- class="text-sky-400 hover:text-sky-300 hover:underline text-sm"
375
- x-text="tunnel.showPanel ? 'hide tunnel options' : 'manage tunnel'"
376
- ></button>
377
- </template>
378
- <!-- Tunnel Panel (collapsible in cursor view) -->
379
- <template x-if="tunnel.showPanel">
380
- <div class="rounded-lg border border-neutral-800 bg-neutral-900/50 p-2 space-y-3">
381
- <!-- Provider Tabs -->
382
- <div class="flex flex-wrap gap-2" x-show="tunnel.providers.length > 0">
383
- <template x-for="p in tunnel.providers.filter(p => p.available)" :key="p.id">
384
- <button
385
- @click="tunnel.selectedProvider = p.id"
386
- :class="tunnel.selectedProvider === p.id ? 'bg-neutral-700 text-white border-neutral-600' : 'bg-neutral-950 text-neutral-400 border-neutral-800'"
387
- class="inline-flex items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition"
388
- x-text="p.name"
389
- ></button>
390
- </template>
391
- </div>
392
- <!-- No providers -->
393
- <p x-show="tunnel.providers.length === 0 && !tunnel.loading" class="text-sm text-neutral-400">
394
- No tunnel providers found (install cloudflared, ngrok, or tailscale).
395
- </p>
396
- <!-- Loading -->
397
- <p x-show="tunnel.loading" class="text-sm text-neutral-400">Loading providers...</p>
398
- <!-- Active Dynamic Tunnel (with stop button) -->
399
- <template x-if="tunnel.status.active">
400
- <div class="space-y-2">
401
- <div class="flex items-center gap-2 text-sm">
402
- <span class="inline-flex h-2 w-2 rounded-full bg-emerald-400"></span>
403
- <span class="font-semibold text-emerald-400">Active</span>
404
- </div>
405
- <input
406
- type="text"
407
- :value="tunnel.status.publicUrl + '/v1'"
408
- readonly
409
- @focus="$el.select()"
410
- class="w-full rounded-lg border border-emerald-500/30 bg-neutral-950 px-3 py-2 text-xs text-neutral-100 font-mono"
411
- >
412
- <button
413
- @click="stopTunnel()"
414
- class="w-full rounded-lg bg-red-500/90 px-3 py-2 text-sm font-semibold text-white hover:bg-red-600"
415
- >Stop Tunnel</button>
416
- </div>
417
- </template>
418
- <!-- Start Form (when no dynamic tunnel and not pre-configured) -->
419
- <template x-if="!tunnel.status.active && !hasExternalUrl && tunnel.providers.some(p => p.available)">
420
- <div class="space-y-2">
421
- <input
422
- x-show="tunnel.providers.find(p => p.id === tunnel.selectedProvider)?.supportsNamedTunnels"
423
- type="text"
424
- x-model="tunnel.customDomain"
425
- placeholder="Custom domain (optional)"
426
- class="w-full rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-600 focus:outline-none"
427
- >
428
- <button
429
- @click="startTunnel()"
430
- :disabled="tunnel.starting"
431
- class="w-full rounded-lg bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-950 hover:bg-white disabled:opacity-50"
432
- x-text="tunnel.starting ? 'Starting...' : 'Start Tunnel'"
433
- ></button>
434
- </div>
435
- </template>
436
- <!-- Error -->
437
- <p x-show="tunnel.error" class="text-sm text-red-400" x-text="tunnel.error"></p>
438
- </div>
439
- </template>
411
+ <div class="flex-1 space-y-1">
412
+ <div class="relative w-full">
413
+ <span class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs pr-1">Set Base URL:</span>
414
+ <input
415
+ type="text"
416
+ :value="hasTunnel ? effectivePublicUrl + '/v1' : ''"
417
+ readonly
418
+ @focus="$el.select()"
419
+ :placeholder="hasTunnel ? '' : 'Create a tunnel first...'"
420
+ class="rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-xs text-neutral-100 focus:border-neutral-600 focus:outline-none font-mono pl-[100px] w-full"
421
+ >
422
+ </div>
440
423
  </div>
441
424
  </div>
442
425
  </li>
@@ -464,7 +447,6 @@
464
447
  // Auth state
465
448
  claude: {
466
449
  accounts: {}, // Map of email -> account data
467
- activeAccount: null, // Currently active email
468
450
  connected: false,
469
451
  code: '',
470
452
  loading: false,
@@ -473,7 +455,6 @@
473
455
  },
474
456
  chatgpt: {
475
457
  accounts: {}, // Map of accountId -> account data
476
- activeAccount: null, // Currently active accountId
477
458
  connected: false,
478
459
  deviceId: '',
479
460
  sessionId: null,
@@ -497,7 +478,8 @@
497
478
  loading: false,
498
479
  starting: false,
499
480
  error: null,
500
- customDomain: ''
481
+ customDomain: '',
482
+ detectedDomains: []
501
483
  },
502
484
 
503
485
  // Server config
@@ -532,6 +514,32 @@
532
514
  return this.tunnel.status.active || this.hasExternalUrl;
533
515
  },
534
516
 
517
+ // Selected tunnel provider name (falls back to generic label)
518
+ get selectedTunnelName() {
519
+ const selected = this.tunnel.providers.find(p => p.id === this.tunnel.selectedProvider);
520
+ return selected ? selected.name : 'Tunnel';
521
+ },
522
+
523
+ get firstClaudeAccountId() {
524
+ return Object.keys(this.claude.accounts)[0] || '';
525
+ },
526
+
527
+ get firstChatGptAccountId() {
528
+ return Object.keys(this.chatgpt.accounts)[0] || '';
529
+ },
530
+
531
+ // Label text for the tunnel/domain input
532
+ get tunnelLabel() {
533
+ if (this.hasTunnel) {
534
+ return 'OpenAI Base URL';
535
+ }
536
+ if (!this.tunnel.selectedProvider) {
537
+ return 'Select a tunnel provider';
538
+ }
539
+ const port = this.port || 'PORT';
540
+ return `Your ${this.selectedTunnelName} tunnel listens on localhost:${port}`;
541
+ },
542
+
535
543
  // Debounce timer
536
544
  _debounce: null,
537
545
 
@@ -539,10 +547,13 @@
539
547
  get apiKey() {
540
548
  const keys = [];
541
549
  const claudeMappings = new Map();
550
+ const fallbackClaudeAccountId = this.firstClaudeAccountId;
542
551
 
543
552
  for (const route of this.routes) {
544
553
  if (!route.cursor || !route.claude) continue;
545
- const accountId = route.account || this.claude.activeAccount;
554
+ const accountId = route.account && this.claude.accounts[route.account]
555
+ ? route.account
556
+ : fallbackClaudeAccountId;
546
557
  if (!accountId) continue;
547
558
  const claudeAccount = this.claude.accounts[accountId];
548
559
  if (!claudeAccount || !claudeAccount.token) continue;
@@ -558,14 +569,16 @@
558
569
  keys.push(`${group.mappings.join(',')}:${group.token}`);
559
570
  }
560
571
 
561
- const chatgptAccount = this.chatgpt.activeAccount ? this.chatgpt.accounts[this.chatgpt.activeAccount] : null;
572
+ const chatgptAccountId = this.firstChatGptAccountId;
573
+ const chatgptAccount = chatgptAccountId ? this.chatgpt.accounts[chatgptAccountId] : null;
562
574
  if (chatgptAccount && chatgptAccount.token) {
563
575
  const chatgptKey = chatgptAccount.accountId
564
576
  ? `${chatgptAccount.token}#${chatgptAccount.accountId}`
565
577
  : chatgptAccount.token;
566
578
  keys.push(chatgptKey);
567
579
  } else if (!keys.length) {
568
- const claudeAccount = this.claude.activeAccount ? this.claude.accounts[this.claude.activeAccount] : null;
580
+ const claudeAccountId = this.firstClaudeAccountId;
581
+ const claudeAccount = claudeAccountId ? this.claude.accounts[claudeAccountId] : null;
569
582
  if (claudeAccount && claudeAccount.token) {
570
583
  keys.push(claudeAccount.token);
571
584
  }
@@ -598,6 +611,12 @@
598
611
  localStorage.removeItem('chatgpt_account_id');
599
612
  console.log('[Migration] Cleared old tokens - please re-login');
600
613
  }
614
+ if (localStorage.getItem('claude_active_account')) {
615
+ localStorage.removeItem('claude_active_account');
616
+ }
617
+ if (localStorage.getItem('chatgpt_active_account')) {
618
+ localStorage.removeItem('chatgpt_active_account');
619
+ }
601
620
 
602
621
  // Load Claude accounts from localStorage
603
622
  const savedClaudeAccounts = localStorage.getItem('claude_accounts');
@@ -608,11 +627,7 @@
608
627
  console.error('Failed to parse Claude accounts:', e);
609
628
  }
610
629
  }
611
- const savedClaudeActive = localStorage.getItem('claude_active_account');
612
- if (savedClaudeActive && this.claude.accounts[savedClaudeActive]) {
613
- this.claude.activeAccount = savedClaudeActive;
614
- this.claude.connected = true;
615
- }
630
+ this.claude.connected = Object.keys(this.claude.accounts).length > 0;
616
631
 
617
632
  // Load ChatGPT accounts from localStorage
618
633
  const savedChatGPTAccounts = localStorage.getItem('chatgpt_accounts');
@@ -623,17 +638,19 @@
623
638
  console.error('Failed to parse ChatGPT accounts:', e);
624
639
  }
625
640
  }
626
- const savedChatGPTActive = localStorage.getItem('chatgpt_active_account');
627
- if (savedChatGPTActive && this.chatgpt.accounts[savedChatGPTActive]) {
628
- this.chatgpt.activeAccount = savedChatGPTActive;
629
- this.chatgpt.connected = true;
630
- }
641
+ this.chatgpt.connected = Object.keys(this.chatgpt.accounts).length > 0;
631
642
 
632
643
  try {
633
644
  const res = await fetch('/api/config');
634
645
  const config = await res.json();
635
646
  this.publicUrl = config.publicUrl;
636
647
  this.port = config.port;
648
+ if (Array.isArray(config.cloudflaredHostnames)) {
649
+ this.tunnel.detectedDomains = config.cloudflaredHostnames;
650
+ }
651
+ if (!this.hasTunnel && !this.tunnel.customDomain && this.tunnel.detectedDomains.length > 0) {
652
+ this.tunnel.customDomain = this.tunnel.detectedDomains[0];
653
+ }
637
654
  } catch (e) {
638
655
  console.error('Failed to fetch config:', e);
639
656
  }
@@ -681,7 +698,6 @@
681
698
  email: email,
682
699
  displayName: email
683
700
  };
684
- this.claude.activeAccount = email;
685
701
  this.claude.connected = true;
686
702
  this.claude.code = '';
687
703
  this.claude.showInput = false;
@@ -692,7 +708,6 @@
692
708
 
693
709
  // Save to localStorage
694
710
  localStorage.setItem('claude_accounts', JSON.stringify(this.claude.accounts));
695
- localStorage.setItem('claude_active_account', email);
696
711
 
697
712
  // If in OAuth flow, complete it
698
713
  if (this.oauth.active) {
@@ -712,35 +727,21 @@
712
727
  const hasRoutes = this.routes.some(route => route.cursor && route.claude);
713
728
  if (hasRoutes) return;
714
729
  this.routes = [
715
- { cursor: 'o3', claude: 'opus-4.5', account: this.claude.activeAccount || '' },
716
- { cursor: 'o3-mini', claude: 'sonnet-4.5', account: this.claude.activeAccount || '' }
730
+ { cursor: 'o3', claude: 'opus-4.5', account: this.firstClaudeAccountId || '' },
731
+ { cursor: 'o3-mini', claude: 'sonnet-4.5', account: this.firstClaudeAccountId || '' }
717
732
  ];
718
733
  },
719
734
 
720
- // Disconnect Claude and clear active account
721
- disconnectClaude() {
722
- if (this.claude.activeAccount) {
723
- // Remove active account
724
- delete this.claude.accounts[this.claude.activeAccount];
725
- this.claude.activeAccount = null;
726
-
727
- // If other accounts exist, switch to first one
728
- const remainingAccounts = Object.keys(this.claude.accounts);
729
- if (remainingAccounts.length > 0) {
730
- this.claude.activeAccount = remainingAccounts[0];
731
- this.claude.connected = true;
732
- } else {
733
- this.claude.connected = false;
734
- }
735
-
736
- // Update localStorage
737
- localStorage.setItem('claude_accounts', JSON.stringify(this.claude.accounts));
738
- if (this.claude.activeAccount) {
739
- localStorage.setItem('claude_active_account', this.claude.activeAccount);
740
- } else {
741
- localStorage.removeItem('claude_active_account');
742
- }
735
+ // Disconnect Claude account
736
+ disconnectClaude(accountId) {
737
+ if (!accountId || !this.claude.accounts[accountId]) return;
738
+ delete this.claude.accounts[accountId];
739
+ this.claude.connected = Object.keys(this.claude.accounts).length > 0;
740
+ const fallbackAccountId = this.firstClaudeAccountId;
741
+ for (const route of this.routes) {
742
+ if (route.account === accountId) route.account = fallbackAccountId || '';
743
743
  }
744
+ localStorage.setItem('claude_accounts', JSON.stringify(this.claude.accounts));
744
745
 
745
746
  this.claude.code = '';
746
747
  this.claude.showInput = false;
@@ -793,14 +794,12 @@
793
794
  email: email,
794
795
  displayName: displayName
795
796
  };
796
- this.chatgpt.activeAccount = accountId;
797
797
  this.chatgpt.connected = true;
798
798
  this.chatgpt.polling = false;
799
799
  this.chatgpt.showInput = false;
800
800
 
801
801
  // Save to localStorage
802
802
  localStorage.setItem('chatgpt_accounts', JSON.stringify(this.chatgpt.accounts));
803
- localStorage.setItem('chatgpt_active_account', accountId);
804
803
 
805
804
  // If in OAuth flow, complete it
806
805
  if (this.oauth.active) {
@@ -818,30 +817,12 @@
818
817
  }
819
818
  },
820
819
 
821
- // Disconnect ChatGPT and clear active account
822
- disconnectChatGPT() {
823
- if (this.chatgpt.activeAccount) {
824
- // Remove active account
825
- delete this.chatgpt.accounts[this.chatgpt.activeAccount];
826
- this.chatgpt.activeAccount = null;
827
-
828
- // If other accounts exist, switch to first one
829
- const remainingAccounts = Object.keys(this.chatgpt.accounts);
830
- if (remainingAccounts.length > 0) {
831
- this.chatgpt.activeAccount = remainingAccounts[0];
832
- this.chatgpt.connected = true;
833
- } else {
834
- this.chatgpt.connected = false;
835
- }
836
-
837
- // Update localStorage
838
- localStorage.setItem('chatgpt_accounts', JSON.stringify(this.chatgpt.accounts));
839
- if (this.chatgpt.activeAccount) {
840
- localStorage.setItem('chatgpt_active_account', this.chatgpt.activeAccount);
841
- } else {
842
- localStorage.removeItem('chatgpt_active_account');
843
- }
844
- }
820
+ // Disconnect ChatGPT account
821
+ disconnectChatGPT(accountId) {
822
+ if (!accountId || !this.chatgpt.accounts[accountId]) return;
823
+ delete this.chatgpt.accounts[accountId];
824
+ this.chatgpt.connected = Object.keys(this.chatgpt.accounts).length > 0;
825
+ localStorage.setItem('chatgpt_accounts', JSON.stringify(this.chatgpt.accounts));
845
826
 
846
827
  this.chatgpt.deviceId = '';
847
828
  this.chatgpt.sessionId = null;
@@ -877,11 +858,15 @@
877
858
  if (!this.tunnel.selectedProvider) return;
878
859
  this.tunnel.starting = true;
879
860
  this.tunnel.error = null;
861
+ const rawDomain = (this.tunnel.customDomain || '').trim();
862
+ const namedUrl = rawDomain
863
+ ? rawDomain.replace(/^https?:\/\//, '').split('/')[0]
864
+ : undefined;
880
865
  try {
881
866
  const res = await fetch(`/api/tunnels/${this.tunnel.selectedProvider}/start`, {
882
867
  method: 'POST',
883
868
  headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
884
- body: JSON.stringify({ namedUrl: this.tunnel.customDomain || undefined })
869
+ body: JSON.stringify({ namedUrl })
885
870
  });
886
871
  const data = await res.json();
887
872
  if (data.success) {
@@ -924,9 +909,11 @@
924
909
  try {
925
910
  // Build providers object with current credentials
926
911
  const providers = {};
912
+ const claudeAccountId = this.firstClaudeAccountId;
913
+ const chatgptAccountId = this.firstChatGptAccountId;
927
914
 
928
- if (this.claude.connected && this.claude.activeAccount) {
929
- const account = this.claude.accounts[this.claude.activeAccount];
915
+ if (this.claude.connected && claudeAccountId) {
916
+ const account = this.claude.accounts[claudeAccountId];
930
917
  if (account) {
931
918
  providers.claude = {
932
919
  access_token: account.token,
@@ -935,8 +922,8 @@
935
922
  }
936
923
  }
937
924
 
938
- if (this.chatgpt.connected && this.chatgpt.activeAccount) {
939
- const account = this.chatgpt.accounts[this.chatgpt.activeAccount];
925
+ if (this.chatgpt.connected && chatgptAccountId) {
926
+ const account = this.chatgpt.accounts[chatgptAccountId];
940
927
  if (account) {
941
928
  providers.chatgpt = {
942
929
  access_token: account.token,
@@ -951,7 +938,7 @@
951
938
  }
952
939
 
953
940
  // Get user ID (email or account ID)
954
- const userId = this.claude.activeAccount || this.chatgpt.activeAccount || 'anonymous';
941
+ const userId = claudeAccountId || chatgptAccountId || 'anonymous';
955
942
 
956
943
  // Request authorization code
957
944
  const res = await fetch('/oauth/code', {