pi-free 2.0.6 → 2.0.8

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 (36) hide show
  1. package/CHANGELOG.md +540 -421
  2. package/README.md +572 -495
  3. package/config.ts +58 -11
  4. package/constants.ts +12 -0
  5. package/index.ts +66 -2
  6. package/lib/model-detection.ts +1 -0
  7. package/lib/model-enhancer.ts +20 -20
  8. package/lib/open-browser.ts +1 -1
  9. package/lib/quota-monitor.ts +123 -0
  10. package/lib/types.ts +101 -101
  11. package/lib/util.ts +460 -351
  12. package/package.json +68 -68
  13. package/provider-failover/benchmark-lookup.ts +743 -702
  14. package/provider-failover/benchmarks-chunk-0.ts +48 -48
  15. package/provider-failover/benchmarks-chunk-1.ts +44 -44
  16. package/provider-failover/benchmarks-chunk-2.ts +39 -39
  17. package/provider-failover/benchmarks-chunk-3.ts +41 -41
  18. package/provider-failover/benchmarks-chunk-4.ts +33 -33
  19. package/providers/cline/cline-auth.ts +473 -473
  20. package/providers/cline/cline-models.ts +2 -2
  21. package/providers/cline/cline.ts +1 -1
  22. package/providers/codestral/codestral.ts +139 -0
  23. package/providers/crofai/crofai.ts +14 -85
  24. package/providers/deepinfra/deepinfra.ts +109 -0
  25. package/providers/kilo/kilo-auth.ts +155 -155
  26. package/providers/kilo/kilo.ts +1 -1
  27. package/providers/llm7/llm7.ts +156 -0
  28. package/providers/model-fetcher.ts +2 -2
  29. package/providers/nvidia/nvidia.ts +4 -4
  30. package/providers/ollama/ollama.ts +1 -1
  31. package/providers/opencode-session.ts +1 -1
  32. package/providers/qwen/qwen-models.ts +101 -101
  33. package/providers/qwen/qwen.ts +1 -1
  34. package/providers/sambanova/sambanova.ts +109 -0
  35. package/providers/zenmux/zenmux.ts +5 -2
  36. package/scripts/check-extensions.mjs +6 -4
@@ -1,473 +1,473 @@
1
- /**
2
- * Cline OAuth login flow — based on pi-cline's proven implementation.
3
- *
4
- * Flow:
5
- * 1. Start local callback server (scans ports 48801-48811)
6
- * 2. Fetch redirect URL from /auth/authorize
7
- * 3. Open browser to OAuth login page
8
- * 4. Capture authorization code via callback (refreshToken/idToken/code)
9
- * 5. Exchange code for access/refresh tokens
10
- */
11
-
12
- import * as http from "node:http";
13
- import { URL as NodeURL } from "node:url";
14
- import type {
15
- OAuthCredentials,
16
- OAuthLoginCallbacks,
17
- } from "@mariozechner/pi-ai";
18
- import { BASE_URL_CLINE, CLINE_AUTH_TIMEOUT_MS } from "../../constants.ts";
19
- import { createLogger } from "../../lib/logger.ts";
20
-
21
- const logger = createLogger("cline-auth");
22
-
23
- // =============================================================================
24
- // Port range for callback server (matches official Cline CLI AuthHandler)
25
- const CALLBACK_PORT_START = 48801;
26
- const CALLBACK_PORT_END = 48811;
27
- const AUTH_PATH = "/auth";
28
-
29
- // =============================================================================
30
- // Headers (must match real Cline VS Code extension exactly)
31
- const VS_CODE_VERSION = "1.109.3";
32
- const CLINE_EXTENSION_VERSION = "3.76.0";
33
-
34
- function buildClineHeaders(): Record<string, string> {
35
- return {
36
- Accept: "application/json",
37
- "Content-Type": "application/json",
38
- "User-Agent": `Cline/${CLINE_EXTENSION_VERSION}`,
39
- "X-PLATFORM": "Visual Studio Code",
40
- "X-PLATFORM-VERSION": VS_CODE_VERSION,
41
- "X-CLIENT-TYPE": "VSCode Extension",
42
- "X-CLIENT-VERSION": CLINE_EXTENSION_VERSION,
43
- "X-CORE-VERSION": CLINE_EXTENSION_VERSION,
44
- };
45
- }
46
-
47
- // =============================================================================
48
- // Callback server
49
- // =============================================================================
50
-
51
- interface CallbackResult {
52
- code: string;
53
- provider: string | null;
54
- }
55
-
56
- function tryListenOnPort(server: http.Server, port: number): Promise<void> {
57
- return new Promise((resolve, reject) => {
58
- const onError = (err: NodeJS.ErrnoException) => {
59
- server.off("error", onError);
60
- reject(err);
61
- };
62
- server.once("error", onError);
63
- server.listen(port, "127.0.0.1", () => {
64
- server.off("error", onError);
65
- resolve();
66
- });
67
- });
68
- }
69
-
70
- function parseCallback(rawUrl: string, port: number): CallbackResult {
71
- const parsed = new NodeURL(rawUrl, `http://127.0.0.1:${port}`);
72
- const query = new URLSearchParams(
73
- parsed.search.slice(1).replace(/\+/g, "%2B"),
74
- );
75
-
76
- const token =
77
- query.get("refreshToken") || query.get("idToken") || query.get("code");
78
- if (!token) {
79
- throw new Error("Missing authorization code in callback URL");
80
- }
81
-
82
- return { code: token, provider: query.get("provider") };
83
- }
84
-
85
- async function startCallbackServer(signal?: AbortSignal): Promise<{
86
- callbackUrl: string;
87
- waitForCode: Promise<CallbackResult>;
88
- close: () => void;
89
- port: number;
90
- }> {
91
- const ports = Array.from(
92
- { length: CALLBACK_PORT_END - CALLBACK_PORT_START + 1 },
93
- (_, i) => CALLBACK_PORT_START + i,
94
- );
95
-
96
- let selectedPort = 0;
97
- let settled = false;
98
- let serverTimeout: NodeJS.Timeout | undefined;
99
- let abortListener: (() => void) | undefined;
100
-
101
- let resolveWait: ((r: CallbackResult) => void) | undefined;
102
- let rejectWait: ((e: Error) => void) | undefined;
103
-
104
- const waitForCode = new Promise<CallbackResult>((resolve, reject) => {
105
- resolveWait = resolve;
106
- rejectWait = reject;
107
- });
108
- void waitForCode.catch(() => {});
109
-
110
- const successHTML = `<!DOCTYPE html><html><head><meta charset="UTF-8">
111
- <title>Cline Auth</title>
112
- <style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
113
- font-family:system-ui,sans-serif;background:#fff;color:#333}
114
- .box{text-align:center;padding:24px;border:1px solid #e1e1e1;border-radius:8px;background:#f8f8f8}
115
- .ok{color:#2f855a;font-size:20px;margin-bottom:8px}</style></head>
116
- <body><div class="box"><div class="ok">✓ Authenticated</div>
117
- <p>You can close this window and return to your terminal.</p></div></body></html>`;
118
-
119
- const cleanup = () => {
120
- if (serverTimeout) {
121
- clearTimeout(serverTimeout);
122
- serverTimeout = undefined;
123
- }
124
- if (signal && abortListener) {
125
- signal.removeEventListener("abort", abortListener);
126
- abortListener = undefined;
127
- }
128
- if (server) {
129
- server.close();
130
- server = undefined as any;
131
- }
132
- };
133
-
134
- const settle = (fn: () => void) => {
135
- if (settled) return;
136
- settled = true;
137
- cleanup();
138
- fn();
139
- };
140
-
141
- let server = http.createServer((req, res) => {
142
- try {
143
- const parsed = new NodeURL(
144
- req.url ?? "",
145
- `http://127.0.0.1:${selectedPort}`,
146
- );
147
- if (parsed.pathname !== AUTH_PATH) {
148
- res.writeHead(404);
149
- res.end("Not found");
150
- settle(() =>
151
- rejectWait?.(new Error(`Unexpected path: ${parsed.pathname}`)),
152
- );
153
- return;
154
- }
155
- const callback = parseCallback(req.url!, selectedPort);
156
- res.writeHead(200, { "Content-Type": "text/html" });
157
- res.end(successHTML);
158
- settle(() => resolveWait?.(callback));
159
- } catch (error) {
160
- res.writeHead(400);
161
- res.end("Bad request");
162
- settle(() =>
163
- rejectWait?.(
164
- error instanceof Error ? error : new Error("Callback parse failed"),
165
- ),
166
- );
167
- }
168
- });
169
-
170
- // Scan port range
171
- for (const port of ports) {
172
- try {
173
- await tryListenOnPort(server, port);
174
- selectedPort = port;
175
- break;
176
- } catch (err) {
177
- if ((err as NodeJS.ErrnoException).code !== "EADDRINUSE") throw err;
178
- }
179
- }
180
-
181
- if (selectedPort === 0) {
182
- cleanup();
183
- throw new Error(
184
- `No available port for auth callback (tried ${ports[0]}-${ports[ports.length - 1]})`,
185
- );
186
- }
187
-
188
- serverTimeout = setTimeout(() => {
189
- settle(() => rejectWait?.(new Error("Callback server timed out")));
190
- }, CLINE_AUTH_TIMEOUT_MS);
191
-
192
- abortListener = () =>
193
- settle(() => rejectWait?.(new Error("Login cancelled")));
194
- if (signal) {
195
- signal.addEventListener("abort", abortListener, { once: true });
196
- if (signal.aborted) abortListener();
197
- }
198
-
199
- return {
200
- callbackUrl: `http://127.0.0.1:${selectedPort}${AUTH_PATH}`,
201
- waitForCode,
202
- port: selectedPort,
203
- close: () => settle(() => rejectWait?.(new Error("Login cancelled"))),
204
- };
205
- }
206
-
207
- // =============================================================================
208
- // Auth URL fetching
209
- // =============================================================================
210
-
211
- async function fetchAuthorizeUrl(
212
- callbackUrl: string,
213
- signal?: AbortSignal,
214
- ): Promise<string> {
215
- const authUrl = new NodeURL("auth/authorize", `${BASE_URL_CLINE}/`);
216
- authUrl.searchParams.set("client_type", "extension");
217
- authUrl.searchParams.set("callback_url", callbackUrl);
218
- authUrl.searchParams.set("redirect_uri", callbackUrl);
219
-
220
- const controller = new AbortController();
221
- const timeout = setTimeout(() => controller.abort(), 8000);
222
-
223
- try {
224
- const res = await fetch(authUrl.toString(), {
225
- method: "GET",
226
- redirect: "manual",
227
- credentials: "include",
228
- headers: buildClineHeaders(),
229
- signal: signal ?? controller.signal,
230
- });
231
-
232
- if (res.status >= 300 && res.status < 400) {
233
- const location = res.headers.get("Location");
234
- if (location) return location;
235
- throw new Error("No redirect URL found in auth response");
236
- }
237
-
238
- const json = (await res.json()) as { redirect_url?: string };
239
- if (
240
- typeof json?.redirect_url === "string" &&
241
- json.redirect_url.length > 0
242
- ) {
243
- return json.redirect_url;
244
- }
245
- throw new Error("Unexpected response from auth server");
246
- } catch (error) {
247
- throw new Error(
248
- `Authentication request failed: ${error instanceof Error ? error.message : "unknown error"}`,
249
- );
250
- } finally {
251
- clearTimeout(timeout);
252
- }
253
- }
254
-
255
- // =============================================================================
256
- // Code input handling
257
- // =============================================================================
258
-
259
- function parseManualInput(input: string): {
260
- code: string;
261
- provider: string | null;
262
- } {
263
- const trimmed = input.trim();
264
-
265
- if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
266
- const cb = new NodeURL(trimmed);
267
- const urlCode =
268
- cb.searchParams.get("refreshToken") ||
269
- cb.searchParams.get("idToken") ||
270
- cb.searchParams.get("code");
271
- if (!urlCode) throw new Error("No code found in callback URL");
272
- return { code: urlCode, provider: cb.searchParams.get("provider") };
273
- }
274
-
275
- return { code: trimmed, provider: null };
276
- }
277
-
278
- type AuthCodeResult =
279
- | { type: "local"; code: string; provider: string | null }
280
- | { type: "manual"; code: string; provider: string | null };
281
-
282
- async function waitForAuthCode(
283
- callbackServer: { waitForCode: Promise<CallbackResult>; close: () => void },
284
- onManualInput: OAuthLoginCallbacks["onManualCodeInput"],
285
- signal?: AbortSignal,
286
- ): Promise<AuthCodeResult> {
287
- if (!onManualInput) {
288
- const result = await callbackServer.waitForCode;
289
- return { type: "local", ...result };
290
- }
291
-
292
- const result = await Promise.race([
293
- callbackServer.waitForCode.then((r) => ({ type: "local" as const, ...r })),
294
- onManualInput().then((c) => ({ type: "manual" as const, code: c })),
295
- ]);
296
-
297
- if (result.type === "local") {
298
- return result;
299
- }
300
-
301
- // Manual input - close server and parse
302
- callbackServer.close();
303
- if (signal?.aborted) throw new Error("Login cancelled");
304
- if (!result.code?.trim()) throw new Error("No code provided");
305
-
306
- const parsed = parseManualInput(result.code);
307
- return { type: "manual", ...parsed };
308
- }
309
-
310
- // =============================================================================
311
- // Token exchange
312
- // =============================================================================
313
-
314
- interface TokenData {
315
- accessToken: string;
316
- refreshToken?: string;
317
- expiresAt: string;
318
- }
319
-
320
- async function exchangeCodeForTokens(
321
- code: string,
322
- provider: string | null,
323
- callbackUrl: string,
324
- signal?: AbortSignal,
325
- ): Promise<TokenData> {
326
- const providerCandidates: Array<string | null> = provider
327
- ? [provider]
328
- : [null, "google", "github", "microsoft", "authkit"];
329
-
330
- let tokenData: TokenData | null = null;
331
- let lastError = "";
332
-
333
- for (const candidate of providerCandidates) {
334
- const payload: Record<string, string> = {
335
- grant_type: "authorization_code",
336
- code,
337
- client_type: "extension",
338
- redirect_uri: callbackUrl,
339
- };
340
- if (candidate) payload.provider = candidate;
341
-
342
- const res = await fetch(`${BASE_URL_CLINE}/auth/token`, {
343
- method: "POST",
344
- headers: buildClineHeaders(),
345
- body: JSON.stringify(payload),
346
- signal,
347
- });
348
-
349
- if (!res.ok) {
350
- lastError = `${res.status}: ${(await res.text().catch(() => "")).slice(0, 120)}`;
351
- continue;
352
- }
353
-
354
- const data = (await res.json()) as {
355
- success?: boolean;
356
- data?: TokenData;
357
- };
358
-
359
- if (data?.success && data.data?.accessToken) {
360
- tokenData = data.data;
361
- break;
362
- }
363
- lastError = "Invalid token response";
364
- }
365
-
366
- if (!tokenData) {
367
- throw new Error(
368
- `Cline token exchange failed${lastError ? ` (${lastError})` : ""}`,
369
- );
370
- }
371
-
372
- return tokenData;
373
- }
374
-
375
- function parseExpiresAt(expiresAt: string): number {
376
- const ms = Date.parse(expiresAt);
377
- if (Number.isNaN(ms))
378
- throw new Error("Cline auth response has invalid expiresAt");
379
- return Math.max(Date.now() + 30_000, ms - 5 * 60_000);
380
- }
381
-
382
- // =============================================================================
383
- // Public API
384
- // =============================================================================
385
-
386
- export async function loginCline(
387
- callbacks: OAuthLoginCallbacks,
388
- ): Promise<OAuthCredentials> {
389
- callbacks.onProgress?.("Preparing Cline authentication...");
390
-
391
- const callbackServer = await startCallbackServer(callbacks.signal);
392
- logger.debug("Callback server started", { port: callbackServer.port });
393
-
394
- try {
395
- const authUrl = await fetchAuthorizeUrl(
396
- callbackServer.callbackUrl,
397
- callbacks.signal,
398
- );
399
- logger.debug("Auth URL fetched");
400
-
401
- callbacks.onAuth({
402
- url: authUrl,
403
- instructions:
404
- "Copy this URL and open it in a new browser tab:\n(The link may wrap — copy the full URL, not just the visible portion)",
405
- });
406
-
407
- callbacks.onProgress?.("Waiting for authentication callback...");
408
-
409
- const { code, provider } = await waitForAuthCode(
410
- callbackServer,
411
- callbacks.onManualCodeInput,
412
- callbacks.signal,
413
- );
414
- logger.debug("Auth code received", {
415
- provider,
416
- type: code.length > 50 ? "token" : "short",
417
- });
418
-
419
- callbacks.onProgress?.("Completing Cline authentication...");
420
-
421
- const tokenData = await exchangeCodeForTokens(
422
- code,
423
- provider,
424
- callbackServer.callbackUrl,
425
- callbacks.signal,
426
- );
427
- logger.info("Login successful");
428
-
429
- return {
430
- access: tokenData.accessToken,
431
- refresh: tokenData.refreshToken ?? "",
432
- expires: parseExpiresAt(tokenData.expiresAt),
433
- };
434
- } finally {
435
- callbackServer.close();
436
- }
437
- }
438
-
439
- export async function refreshClineToken(
440
- credentials: OAuthCredentials,
441
- ): Promise<OAuthCredentials> {
442
- if (credentials.expires > Date.now()) return credentials;
443
-
444
- const res = await fetch(`${BASE_URL_CLINE}/auth/refresh`, {
445
- method: "POST",
446
- headers: buildClineHeaders(),
447
- body: JSON.stringify({
448
- refreshToken: credentials.refresh,
449
- grantType: "refresh_token",
450
- }),
451
- });
452
-
453
- if (!res.ok) {
454
- throw new Error(
455
- "Cline token refresh failed. Run /login cline to re-authenticate.",
456
- );
457
- }
458
-
459
- const data = (await res.json()) as {
460
- success?: boolean;
461
- data?: { accessToken: string; refreshToken?: string; expiresAt: string };
462
- };
463
-
464
- if (!data?.success || !data.data) {
465
- throw new Error("Invalid refresh response");
466
- }
467
-
468
- return {
469
- access: data.data.accessToken,
470
- refresh: data.data.refreshToken ?? credentials.refresh,
471
- expires: parseExpiresAt(data.data.expiresAt),
472
- };
473
- }
1
+ /**
2
+ * Cline OAuth login flow — based on pi-cline's proven implementation.
3
+ *
4
+ * Flow:
5
+ * 1. Start local callback server (scans ports 48801-48811)
6
+ * 2. Fetch redirect URL from /auth/authorize
7
+ * 3. Open browser to OAuth login page
8
+ * 4. Capture authorization code via callback (refreshToken/idToken/code)
9
+ * 5. Exchange code for access/refresh tokens
10
+ */
11
+
12
+ import * as http from "node:http";
13
+ import { URL as NodeURL } from "node:url";
14
+ import type {
15
+ OAuthCredentials,
16
+ OAuthLoginCallbacks,
17
+ } from "@mariozechner/pi-ai";
18
+ import { BASE_URL_CLINE, CLINE_AUTH_TIMEOUT_MS } from "../../constants.ts";
19
+ import { createLogger } from "../../lib/logger.ts";
20
+
21
+ const logger = createLogger("cline-auth");
22
+
23
+ // =============================================================================
24
+ // Port range for callback server (matches official Cline CLI AuthHandler)
25
+ const CALLBACK_PORT_START = 48801;
26
+ const CALLBACK_PORT_END = 48811;
27
+ const AUTH_PATH = "/auth";
28
+
29
+ // =============================================================================
30
+ // Headers (must match real Cline VS Code extension exactly)
31
+ const VS_CODE_VERSION = "1.109.3";
32
+ const CLINE_EXTENSION_VERSION = "3.76.0";
33
+
34
+ function buildClineHeaders(): Record<string, string> {
35
+ return {
36
+ Accept: "application/json",
37
+ "Content-Type": "application/json",
38
+ "User-Agent": `Cline/${CLINE_EXTENSION_VERSION}`,
39
+ "X-PLATFORM": "Visual Studio Code",
40
+ "X-PLATFORM-VERSION": VS_CODE_VERSION,
41
+ "X-CLIENT-TYPE": "VSCode Extension",
42
+ "X-CLIENT-VERSION": CLINE_EXTENSION_VERSION,
43
+ "X-CORE-VERSION": CLINE_EXTENSION_VERSION,
44
+ };
45
+ }
46
+
47
+ // =============================================================================
48
+ // Callback server
49
+ // =============================================================================
50
+
51
+ interface CallbackResult {
52
+ code: string;
53
+ provider: string | null;
54
+ }
55
+
56
+ function tryListenOnPort(server: http.Server, port: number): Promise<void> {
57
+ return new Promise((resolve, reject) => {
58
+ const onError = (err: NodeJS.ErrnoException) => {
59
+ server.off("error", onError);
60
+ reject(err);
61
+ };
62
+ server.once("error", onError);
63
+ server.listen(port, "127.0.0.1", () => {
64
+ server.off("error", onError);
65
+ resolve();
66
+ });
67
+ });
68
+ }
69
+
70
+ function parseCallback(rawUrl: string, port: number): CallbackResult {
71
+ const parsed = new NodeURL(rawUrl, `http://127.0.0.1:${port}`);
72
+ const query = new URLSearchParams(
73
+ parsed.search.slice(1).replaceAll("+", "%2B"),
74
+ );
75
+
76
+ const token =
77
+ query.get("refreshToken") || query.get("idToken") || query.get("code");
78
+ if (!token) {
79
+ throw new Error("Missing authorization code in callback URL");
80
+ }
81
+
82
+ return { code: token, provider: query.get("provider") };
83
+ }
84
+
85
+ async function startCallbackServer(signal?: AbortSignal): Promise<{
86
+ callbackUrl: string;
87
+ waitForCode: Promise<CallbackResult>;
88
+ close: () => void;
89
+ port: number;
90
+ }> {
91
+ const ports = Array.from(
92
+ { length: CALLBACK_PORT_END - CALLBACK_PORT_START + 1 },
93
+ (_, i) => CALLBACK_PORT_START + i,
94
+ );
95
+
96
+ let selectedPort = 0;
97
+ let settled = false;
98
+ let serverTimeout: NodeJS.Timeout | undefined;
99
+ let abortListener: (() => void) | undefined;
100
+
101
+ let resolveWait: ((r: CallbackResult) => void) | undefined;
102
+ let rejectWait: ((e: Error) => void) | undefined;
103
+
104
+ const waitForCode = new Promise<CallbackResult>((resolve, reject) => {
105
+ resolveWait = resolve;
106
+ rejectWait = reject;
107
+ });
108
+ void waitForCode.catch(() => {});
109
+
110
+ const successHTML = `<!DOCTYPE html><html><head><meta charset="UTF-8">
111
+ <title>Cline Auth</title>
112
+ <style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
113
+ font-family:system-ui,sans-serif;background:#fff;color:#333}
114
+ .box{text-align:center;padding:24px;border:1px solid #e1e1e1;border-radius:8px;background:#f8f8f8}
115
+ .ok{color:#2f855a;font-size:20px;margin-bottom:8px}</style></head>
116
+ <body><div class="box"><div class="ok">✓ Authenticated</div>
117
+ <p>You can close this window and return to your terminal.</p></div></body></html>`;
118
+
119
+ const cleanup = () => {
120
+ if (serverTimeout) {
121
+ clearTimeout(serverTimeout);
122
+ serverTimeout = undefined;
123
+ }
124
+ if (signal && abortListener) {
125
+ signal.removeEventListener("abort", abortListener);
126
+ abortListener = undefined;
127
+ }
128
+ if (server) {
129
+ server.close();
130
+ server = undefined as any;
131
+ }
132
+ };
133
+
134
+ const settle = (fn: () => void) => {
135
+ if (settled) return;
136
+ settled = true;
137
+ cleanup();
138
+ fn();
139
+ };
140
+
141
+ let server = http.createServer((req, res) => {
142
+ try {
143
+ const parsed = new NodeURL(
144
+ req.url ?? "",
145
+ `http://127.0.0.1:${selectedPort}`,
146
+ );
147
+ if (parsed.pathname !== AUTH_PATH) {
148
+ res.writeHead(404);
149
+ res.end("Not found");
150
+ settle(() =>
151
+ rejectWait?.(new Error(`Unexpected path: ${parsed.pathname}`)),
152
+ );
153
+ return;
154
+ }
155
+ const callback = parseCallback(req.url!, selectedPort);
156
+ res.writeHead(200, { "Content-Type": "text/html" });
157
+ res.end(successHTML);
158
+ settle(() => resolveWait?.(callback));
159
+ } catch (error) {
160
+ res.writeHead(400);
161
+ res.end("Bad request");
162
+ settle(() =>
163
+ rejectWait?.(
164
+ error instanceof Error ? error : new Error("Callback parse failed"),
165
+ ),
166
+ );
167
+ }
168
+ });
169
+
170
+ // Scan port range
171
+ for (const port of ports) {
172
+ try {
173
+ await tryListenOnPort(server, port);
174
+ selectedPort = port;
175
+ break;
176
+ } catch (err) {
177
+ if ((err as NodeJS.ErrnoException).code !== "EADDRINUSE") throw err;
178
+ }
179
+ }
180
+
181
+ if (selectedPort === 0) {
182
+ cleanup();
183
+ throw new Error(
184
+ `No available port for auth callback (tried ${ports[0]}-${ports.at(-1)})`,
185
+ );
186
+ }
187
+
188
+ serverTimeout = setTimeout(() => {
189
+ settle(() => rejectWait?.(new Error("Callback server timed out")));
190
+ }, CLINE_AUTH_TIMEOUT_MS);
191
+
192
+ abortListener = () =>
193
+ settle(() => rejectWait?.(new Error("Login cancelled")));
194
+ if (signal) {
195
+ signal.addEventListener("abort", abortListener, { once: true });
196
+ if (signal.aborted) abortListener();
197
+ }
198
+
199
+ return {
200
+ callbackUrl: `http://127.0.0.1:${selectedPort}${AUTH_PATH}`,
201
+ waitForCode,
202
+ port: selectedPort,
203
+ close: () => settle(() => rejectWait?.(new Error("Login cancelled"))),
204
+ };
205
+ }
206
+
207
+ // =============================================================================
208
+ // Auth URL fetching
209
+ // =============================================================================
210
+
211
+ async function fetchAuthorizeUrl(
212
+ callbackUrl: string,
213
+ signal?: AbortSignal,
214
+ ): Promise<string> {
215
+ const authUrl = new NodeURL("auth/authorize", `${BASE_URL_CLINE}/`);
216
+ authUrl.searchParams.set("client_type", "extension");
217
+ authUrl.searchParams.set("callback_url", callbackUrl);
218
+ authUrl.searchParams.set("redirect_uri", callbackUrl);
219
+
220
+ const controller = new AbortController();
221
+ const timeout = setTimeout(() => controller.abort(), 8000);
222
+
223
+ try {
224
+ const res = await fetch(authUrl.toString(), {
225
+ method: "GET",
226
+ redirect: "manual",
227
+ credentials: "include",
228
+ headers: buildClineHeaders(),
229
+ signal: signal ?? controller.signal,
230
+ });
231
+
232
+ if (res.status >= 300 && res.status < 400) {
233
+ const location = res.headers.get("Location");
234
+ if (location) return location;
235
+ throw new Error("No redirect URL found in auth response");
236
+ }
237
+
238
+ const json = (await res.json()) as { redirect_url?: string };
239
+ if (
240
+ typeof json?.redirect_url === "string" &&
241
+ json.redirect_url.length > 0
242
+ ) {
243
+ return json.redirect_url;
244
+ }
245
+ throw new Error("Unexpected response from auth server");
246
+ } catch (error) {
247
+ throw new Error(
248
+ `Authentication request failed: ${error instanceof Error ? error.message : "unknown error"}`,
249
+ );
250
+ } finally {
251
+ clearTimeout(timeout);
252
+ }
253
+ }
254
+
255
+ // =============================================================================
256
+ // Code input handling
257
+ // =============================================================================
258
+
259
+ function parseManualInput(input: string): {
260
+ code: string;
261
+ provider: string | null;
262
+ } {
263
+ const trimmed = input.trim();
264
+
265
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
266
+ const cb = new NodeURL(trimmed);
267
+ const urlCode =
268
+ cb.searchParams.get("refreshToken") ||
269
+ cb.searchParams.get("idToken") ||
270
+ cb.searchParams.get("code");
271
+ if (!urlCode) throw new Error("No code found in callback URL");
272
+ return { code: urlCode, provider: cb.searchParams.get("provider") };
273
+ }
274
+
275
+ return { code: trimmed, provider: null };
276
+ }
277
+
278
+ type AuthCodeResult =
279
+ | { type: "local"; code: string; provider: string | null }
280
+ | { type: "manual"; code: string; provider: string | null };
281
+
282
+ async function waitForAuthCode(
283
+ callbackServer: { waitForCode: Promise<CallbackResult>; close: () => void },
284
+ onManualInput: OAuthLoginCallbacks["onManualCodeInput"],
285
+ signal?: AbortSignal,
286
+ ): Promise<AuthCodeResult> {
287
+ if (!onManualInput) {
288
+ const result = await callbackServer.waitForCode;
289
+ return { type: "local", ...result };
290
+ }
291
+
292
+ const result = await Promise.race([
293
+ callbackServer.waitForCode.then((r) => ({ type: "local" as const, ...r })),
294
+ onManualInput().then((c) => ({ type: "manual" as const, code: c })),
295
+ ]);
296
+
297
+ if (result.type === "local") {
298
+ return result;
299
+ }
300
+
301
+ // Manual input - close server and parse
302
+ callbackServer.close();
303
+ if (signal?.aborted) throw new Error("Login cancelled");
304
+ if (!result.code?.trim()) throw new Error("No code provided");
305
+
306
+ const parsed = parseManualInput(result.code);
307
+ return { type: "manual", ...parsed };
308
+ }
309
+
310
+ // =============================================================================
311
+ // Token exchange
312
+ // =============================================================================
313
+
314
+ interface TokenData {
315
+ accessToken: string;
316
+ refreshToken?: string;
317
+ expiresAt: string;
318
+ }
319
+
320
+ async function exchangeCodeForTokens(
321
+ code: string,
322
+ provider: string | null,
323
+ callbackUrl: string,
324
+ signal?: AbortSignal,
325
+ ): Promise<TokenData> {
326
+ const providerCandidates: Array<string | null> = provider
327
+ ? [provider]
328
+ : [null, "google", "github", "microsoft", "authkit"];
329
+
330
+ let tokenData: TokenData | null = null;
331
+ let lastError = "";
332
+
333
+ for (const candidate of providerCandidates) {
334
+ const payload: Record<string, string> = {
335
+ grant_type: "authorization_code",
336
+ code,
337
+ client_type: "extension",
338
+ redirect_uri: callbackUrl,
339
+ };
340
+ if (candidate) payload.provider = candidate;
341
+
342
+ const res = await fetch(`${BASE_URL_CLINE}/auth/token`, {
343
+ method: "POST",
344
+ headers: buildClineHeaders(),
345
+ body: JSON.stringify(payload),
346
+ signal,
347
+ });
348
+
349
+ if (!res.ok) {
350
+ lastError = `${res.status}: ${(await res.text().catch(() => "")).slice(0, 120)}`;
351
+ continue;
352
+ }
353
+
354
+ const data = (await res.json()) as {
355
+ success?: boolean;
356
+ data?: TokenData;
357
+ };
358
+
359
+ if (data?.success && data.data?.accessToken) {
360
+ tokenData = data.data;
361
+ break;
362
+ }
363
+ lastError = "Invalid token response";
364
+ }
365
+
366
+ if (!tokenData) {
367
+ throw new Error(
368
+ `Cline token exchange failed${lastError ? ` (${lastError})` : ""}`,
369
+ );
370
+ }
371
+
372
+ return tokenData;
373
+ }
374
+
375
+ function parseExpiresAt(expiresAt: string): number {
376
+ const ms = Date.parse(expiresAt);
377
+ if (Number.isNaN(ms))
378
+ throw new Error("Cline auth response has invalid expiresAt");
379
+ return Math.max(Date.now() + 30_000, ms - 5 * 60_000);
380
+ }
381
+
382
+ // =============================================================================
383
+ // Public API
384
+ // =============================================================================
385
+
386
+ export async function loginCline(
387
+ callbacks: OAuthLoginCallbacks,
388
+ ): Promise<OAuthCredentials> {
389
+ callbacks.onProgress?.("Preparing Cline authentication...");
390
+
391
+ const callbackServer = await startCallbackServer(callbacks.signal);
392
+ logger.debug("Callback server started", { port: callbackServer.port });
393
+
394
+ try {
395
+ const authUrl = await fetchAuthorizeUrl(
396
+ callbackServer.callbackUrl,
397
+ callbacks.signal,
398
+ );
399
+ logger.debug("Auth URL fetched");
400
+
401
+ callbacks.onAuth({
402
+ url: authUrl,
403
+ instructions:
404
+ "Copy this URL and open it in a new browser tab:\n(The link may wrap — copy the full URL, not just the visible portion)",
405
+ });
406
+
407
+ callbacks.onProgress?.("Waiting for authentication callback...");
408
+
409
+ const { code, provider } = await waitForAuthCode(
410
+ callbackServer,
411
+ callbacks.onManualCodeInput,
412
+ callbacks.signal,
413
+ );
414
+ logger.debug("Auth code received", {
415
+ provider,
416
+ type: code.length > 50 ? "token" : "short",
417
+ });
418
+
419
+ callbacks.onProgress?.("Completing Cline authentication...");
420
+
421
+ const tokenData = await exchangeCodeForTokens(
422
+ code,
423
+ provider,
424
+ callbackServer.callbackUrl,
425
+ callbacks.signal,
426
+ );
427
+ logger.info("Login successful");
428
+
429
+ return {
430
+ access: tokenData.accessToken,
431
+ refresh: tokenData.refreshToken ?? "",
432
+ expires: parseExpiresAt(tokenData.expiresAt),
433
+ };
434
+ } finally {
435
+ callbackServer.close();
436
+ }
437
+ }
438
+
439
+ export async function refreshClineToken(
440
+ credentials: OAuthCredentials,
441
+ ): Promise<OAuthCredentials> {
442
+ if (credentials.expires > Date.now()) return credentials;
443
+
444
+ const res = await fetch(`${BASE_URL_CLINE}/auth/refresh`, {
445
+ method: "POST",
446
+ headers: buildClineHeaders(),
447
+ body: JSON.stringify({
448
+ refreshToken: credentials.refresh,
449
+ grantType: "refresh_token",
450
+ }),
451
+ });
452
+
453
+ if (!res.ok) {
454
+ throw new Error(
455
+ "Cline token refresh failed. Run /login cline to re-authenticate.",
456
+ );
457
+ }
458
+
459
+ const data = (await res.json()) as {
460
+ success?: boolean;
461
+ data?: { accessToken: string; refreshToken?: string; expiresAt: string };
462
+ };
463
+
464
+ if (!data?.success || !data.data) {
465
+ throw new Error("Invalid refresh response");
466
+ }
467
+
468
+ return {
469
+ access: data.data.accessToken,
470
+ refresh: data.data.refreshToken ?? credentials.refresh,
471
+ expires: parseExpiresAt(data.data.expiresAt),
472
+ };
473
+ }