pi-freeflow 1.21.0 → 1.22.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.
- package/CHANGELOG.md +6 -0
- package/README.md +19 -5
- package/package.json +1 -1
- package/src/cline-accounts.ts +221 -25
- package/src/cline-device-auth.ts +335 -0
- package/src/commands.ts +96 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Sign in to Cline right inside the extension: `/freeflow cline login` shows a browser link and code, approves your own Cline account, and saves it to a per-user pool on your machine — no pasting keys. Extra logins auto-name themselves (`default`, `slot-2`, …), saved logins refresh on their own, and requests roll across them when one hits its daily free limit. Pasting a key stays available behind `login --key`. Model selection labels every free model with its source ([OpenCode], [Kilo], [Cline]).
|
|
8
|
+
|
|
3
9
|
## 1.21.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 31 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
|
|
4
4
|
|
|
5
5
|
Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
|
|
|
16
16
|
|
|
17
17
|
| Feature | What it does | Cost |
|
|
18
18
|
| :--- | :--- | :--- |
|
|
19
|
-
| **
|
|
19
|
+
| **31 free models** | 8 from OpenCode Zen, 19 from KiloCode Gateway, 4 from Cline, context windows up to 1M. Full list below. | **$0** |
|
|
20
20
|
| **Relay pool** | Route requests through your own Cloudflare Workers and Vercel Edge relays. Requests rotate across the pool. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
|
|
21
21
|
| **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
|
|
22
22
|
| **Short model names** | Every model has a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`. | **$0** |
|
|
@@ -174,13 +174,16 @@ The same command set works identically in OMP and Pi:
|
|
|
174
174
|
/freeflow update # Check for and install a package update
|
|
175
175
|
/freeflow debug on | off # Toggle full HTTP lifecycle debug logging
|
|
176
176
|
/freeflow kill # Stop the shared proxy daemon now (restarts on next use)
|
|
177
|
+
/freeflow cline login # Sign in through the browser, save to the per-user pool
|
|
178
|
+
/freeflow cline accounts # List saved Cline logins
|
|
179
|
+
/freeflow cline logout # Remove a saved Cline login
|
|
177
180
|
/freeflow export [path] [--include-secrets] # Save the relay pool to a file (default freeflow-relays.json; passwords left out unless asked)
|
|
178
181
|
/freeflow import <path> [--merge|--replace] [--dry-run] # Load a relay pool from a file (merge is default; replace asks first; dry-run previews only)
|
|
179
182
|
```
|
|
180
183
|
|
|
181
184
|
---
|
|
182
185
|
|
|
183
|
-
###
|
|
186
|
+
### 31 models, one command
|
|
184
187
|
|
|
185
188
|
```bash
|
|
186
189
|
/model → freeflow → pick
|
|
@@ -229,6 +232,17 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
|
|
|
229
232
|
|
|
230
233
|
\* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
|
|
231
234
|
|
|
235
|
+
#### Cline (4 models), OpenAI compatible
|
|
236
|
+
|
|
237
|
+
Rotating per-account promo, direct only (never through the relay pool). Sign in through your browser with `/freeflow cline login`, manage with `/freeflow cline accounts` and `/freeflow cline logout`. Requests roll across saved logins when one hits its daily free limit.
|
|
238
|
+
|
|
239
|
+
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
240
|
+
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
241
|
+
| `cline-free/deepseek-v4.1-flash` | DeepSeek | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
242
|
+
| `cline-free/muse-spark-1.3-contributor` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
243
|
+
| `z-ai/glm-5.3-flash` | Zhipu AI | **262K** (262.144) | **64K** (65.536) | `minimal…xhigh`\* | ❌ |
|
|
244
|
+
| `cline-free/solar-pro4` | Upstage | **131K** (131.072) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
245
|
+
|
|
232
246
|
---
|
|
233
247
|
|
|
234
248
|
### Logs and debugging
|
|
@@ -254,7 +268,7 @@ About 19k lines including tests. The full suite (sandboxed, network-mocked) and
|
|
|
254
268
|
### FAQ
|
|
255
269
|
|
|
256
270
|
**Do I need API keys?**
|
|
257
|
-
No. Kilo uses a shared free credential and OpenCode free models need no header. You never paste a key.
|
|
271
|
+
No. Kilo uses a shared free credential and OpenCode free models need no header. You never paste a key. Cline free models need a browser login instead: `/freeflow cline login` saves it to a per-user pool on your machine.
|
|
258
272
|
|
|
259
273
|
**What if all relays hit rate limits?**
|
|
260
274
|
The proxy tries direct upstream. If that is also rate-limited, the host shows the limit. That number is the shared upstream cap; without relays you would hit the same wall sooner.
|
|
@@ -322,7 +336,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
|
322
336
|
```
|
|
323
337
|
src/
|
|
324
338
|
├── index.ts # extension entry, lifecycle hooks
|
|
325
|
-
├── models.ts #
|
|
339
|
+
├── models.ts # 31-model catalog definitions
|
|
326
340
|
├── catalog.ts # model catalog cache (24h disk)
|
|
327
341
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
328
342
|
├── relay.ts # relay selection and round-robin
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.22.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/cline-accounts.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface ClineAccount {
|
|
|
30
30
|
token: string;
|
|
31
31
|
addedAt: string;
|
|
32
32
|
lastOkAt?: number;
|
|
33
|
+
/** WorkOS refresh token for device-login slots; absent on legacy key slots. */
|
|
34
|
+
refreshToken?: string;
|
|
35
|
+
/** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
|
|
36
|
+
expiresAt?: number;
|
|
37
|
+
/** WorkOS account id, kept for display/diagnostics only. */
|
|
38
|
+
accountId?: string;
|
|
39
|
+
/** Login email, kept for display/diagnostics only. */
|
|
40
|
+
email?: string;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface ClinePoolState {
|
|
@@ -37,6 +45,31 @@ export interface ClinePoolState {
|
|
|
37
45
|
activeSlot?: string;
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Fresh tokens minted via the device-login refresh flow. DeviceAuth owns the
|
|
50
|
+
* network call; this module only applies the result to the pool file.
|
|
51
|
+
*/
|
|
52
|
+
export interface ClineRefreshResult {
|
|
53
|
+
token: string;
|
|
54
|
+
refreshToken?: string;
|
|
55
|
+
expiresAt?: number;
|
|
56
|
+
accountId?: string;
|
|
57
|
+
email?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Refresh one slot. Resolves to the fresh tokens, or null when the grant is
|
|
62
|
+
* dead (invalid_grant — the slot must be logged in again). May throw on
|
|
63
|
+
* transient network faults; callers treat a throw as "keep the stale token".
|
|
64
|
+
*/
|
|
65
|
+
export type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
|
|
66
|
+
|
|
67
|
+
/** Usable bearer for one slot, resolved via refresh when stale. */
|
|
68
|
+
export interface ValidSlotCredentials {
|
|
69
|
+
slot: string;
|
|
70
|
+
token: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
/** Cooldowns after failures so a bad key stops absorbing traffic. */
|
|
41
74
|
const AUTH_COOLDOWN_MS = 10 * 60_000;
|
|
42
75
|
const RATE_COOLDOWN_MS = 90_000;
|
|
@@ -112,11 +145,25 @@ function readPoolFile(): ClinePoolState {
|
|
|
112
145
|
const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
|
|
113
146
|
const token = typeof rec.token === "string" ? rec.token : "";
|
|
114
147
|
if (!slot || !token.startsWith("workos:")) continue;
|
|
148
|
+
const refreshRaw: unknown = rec.refreshToken;
|
|
149
|
+
const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
|
|
150
|
+
const expiresRaw: unknown = rec.expiresAt;
|
|
151
|
+
const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
|
|
152
|
+
? expiresRaw
|
|
153
|
+
: undefined;
|
|
154
|
+
const accountIdRaw: unknown = rec.accountId;
|
|
155
|
+
const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
|
|
156
|
+
const emailRaw: unknown = rec.email;
|
|
157
|
+
const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
|
|
115
158
|
accounts.push({
|
|
116
159
|
slot,
|
|
117
160
|
token,
|
|
118
161
|
addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
|
|
119
162
|
...(typeof rec.lastOkAt === "number" ? { lastOkAt: rec.lastOkAt } : {}),
|
|
163
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
164
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
165
|
+
...(accountId ? { accountId } : {}),
|
|
166
|
+
...(email ? { email } : {}),
|
|
120
167
|
});
|
|
121
168
|
}
|
|
122
169
|
const activeRaw: unknown = doc.activeSlot;
|
|
@@ -180,8 +227,14 @@ export function redactedToken(token: string): string {
|
|
|
180
227
|
/**
|
|
181
228
|
* Save (or replace) one login slot. The token must carry the `workos:` prefix.
|
|
182
229
|
* Throws on bad input — the message never echoes the token.
|
|
230
|
+
* Extras carry the device-login grant (refresh token, expiry, identity);
|
|
231
|
+
* legacy key slots keep calling with two args and load untouched.
|
|
183
232
|
*/
|
|
184
|
-
export function addAccount(
|
|
233
|
+
export function addAccount(
|
|
234
|
+
slot: string,
|
|
235
|
+
token: string,
|
|
236
|
+
extras?: { refreshToken?: string; expiresAt?: number; accountId?: string; email?: string },
|
|
237
|
+
): ClinePoolState {
|
|
185
238
|
const cleanSlot = (slot || "").trim();
|
|
186
239
|
if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
|
|
187
240
|
if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
|
|
@@ -190,8 +243,31 @@ export function addAccount(slot: string, token: string): ClinePoolState {
|
|
|
190
243
|
const existing = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
191
244
|
if (existing) {
|
|
192
245
|
existing.token = token;
|
|
246
|
+
if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
|
|
247
|
+
if (extras?.expiresAt !== undefined) {
|
|
248
|
+
if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
|
|
249
|
+
else delete existing.expiresAt;
|
|
250
|
+
}
|
|
251
|
+
if (extras?.accountId !== undefined) {
|
|
252
|
+
if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
|
|
253
|
+
else delete existing.accountId;
|
|
254
|
+
}
|
|
255
|
+
if (extras?.email !== undefined) {
|
|
256
|
+
if (extras.email.trim()) existing.email = extras.email.trim();
|
|
257
|
+
else delete existing.email;
|
|
258
|
+
}
|
|
193
259
|
} else {
|
|
194
|
-
pool.accounts.push({
|
|
260
|
+
pool.accounts.push({
|
|
261
|
+
slot: cleanSlot,
|
|
262
|
+
token,
|
|
263
|
+
addedAt: new Date().toISOString(),
|
|
264
|
+
...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
|
|
265
|
+
...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
|
|
266
|
+
? { expiresAt: extras.expiresAt }
|
|
267
|
+
: {}),
|
|
268
|
+
...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
|
|
269
|
+
...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
|
|
270
|
+
});
|
|
195
271
|
}
|
|
196
272
|
if (!pool.activeSlot) pool.activeSlot = cleanSlot;
|
|
197
273
|
slotCooldowns.delete(cleanSlot);
|
|
@@ -199,6 +275,79 @@ export function addAccount(slot: string, token: string): ClinePoolState {
|
|
|
199
275
|
return pool;
|
|
200
276
|
}
|
|
201
277
|
|
|
278
|
+
/** Skew so a token expiring mid-flight counts as stale before it breaks a call. */
|
|
279
|
+
export const CLINE_REFRESH_SKEW_MS = 60_000;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* True when the slot carries a known expiry that has passed (or passes within
|
|
283
|
+
* the skew window). Slots without `expiresAt` — every legacy key slot — are
|
|
284
|
+
* never stale: they load and serve exactly as before.
|
|
285
|
+
*/
|
|
286
|
+
export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_SKEW_MS): boolean {
|
|
287
|
+
if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false;
|
|
288
|
+
return Date.now() + skewMs >= account.expiresAt;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Refresh outcome for one slot: fresh tokens applied and persisted (`true`),
|
|
293
|
+
* dead grant with nothing to keep (`false`), or a transient fault where the
|
|
294
|
+
* caller should fall back to the stale bearer (`null`).
|
|
295
|
+
*/
|
|
296
|
+
async function refreshAccountInPlace(
|
|
297
|
+
pool: ClinePoolState,
|
|
298
|
+
account: ClineAccount,
|
|
299
|
+
refreshImpl: ClineRefresher,
|
|
300
|
+
): Promise<boolean | null> {
|
|
301
|
+
const current = account.refreshToken;
|
|
302
|
+
if (!current) return null;
|
|
303
|
+
let fresh: ClineRefreshResult | null;
|
|
304
|
+
try {
|
|
305
|
+
fresh = await refreshImpl(current);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
logWarn("cline slot refresh failed — keeping stale bearer", { slot: account.slot });
|
|
308
|
+
void e;
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
if (!fresh || typeof fresh.token !== "string" || !fresh.token.startsWith("workos:")) {
|
|
312
|
+
logWarn("cline slot refresh rejected — grant is dead", { slot: account.slot });
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
account.token = fresh.token;
|
|
316
|
+
if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
|
|
317
|
+
if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
|
|
318
|
+
account.expiresAt = fresh.expiresAt;
|
|
319
|
+
} else {
|
|
320
|
+
delete account.expiresAt;
|
|
321
|
+
}
|
|
322
|
+
if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
|
|
323
|
+
if (fresh.email?.trim()) account.email = fresh.email.trim();
|
|
324
|
+
savePool(pool);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve a usable bearer for one slot. Fresh slots and legacy slots without
|
|
330
|
+
* a known expiry return immediately; stale slots with a refresh grant try one
|
|
331
|
+
* refresh and persist it. Resolves null when the slot is unknown or its grant
|
|
332
|
+
* is dead (invalid_grant) — the caller should prompt for a fresh login.
|
|
333
|
+
*/
|
|
334
|
+
export async function getValidSlotCredentials(
|
|
335
|
+
slot: string,
|
|
336
|
+
opts?: { refreshImpl?: ClineRefresher; skewMs?: number },
|
|
337
|
+
): Promise<ValidSlotCredentials | null> {
|
|
338
|
+
const cleanSlot = (slot || "").trim();
|
|
339
|
+
if (!cleanSlot) return null;
|
|
340
|
+
const pool = loadPool();
|
|
341
|
+
const account = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
342
|
+
if (!account) return null;
|
|
343
|
+
if (!isClineTokenStale(account, opts?.skewMs)) return { slot: account.slot, token: account.token };
|
|
344
|
+
if (!account.refreshToken || !opts?.refreshImpl) return { slot: account.slot, token: account.token };
|
|
345
|
+
const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
|
|
346
|
+
if (outcome === true) return { slot: account.slot, token: account.token };
|
|
347
|
+
if (outcome === false) return null;
|
|
348
|
+
return { slot: account.slot, token: account.token };
|
|
349
|
+
}
|
|
350
|
+
|
|
202
351
|
/** Drop one login slot. Returns false when the slot was not saved. */
|
|
203
352
|
export function removeAccount(slot: string): boolean {
|
|
204
353
|
const cleanSlot = (slot || "").trim();
|
|
@@ -221,6 +370,12 @@ export interface ClineRollOpts {
|
|
|
221
370
|
slots?: string[];
|
|
222
371
|
fetchImpl?: typeof fetch;
|
|
223
372
|
reqId?: string;
|
|
373
|
+
/**
|
|
374
|
+
* Device-login refresher. When present, a stale slot refreshes once before
|
|
375
|
+
* its attempt, and a 401/403 refreshes once before the slot cools out.
|
|
376
|
+
* Absent: every slot serves its stored bearer exactly as before.
|
|
377
|
+
*/
|
|
378
|
+
refreshImpl?: ClineRefresher;
|
|
224
379
|
}
|
|
225
380
|
|
|
226
381
|
export interface ClineRollResult {
|
|
@@ -265,40 +420,81 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
|
265
420
|
return { res: exhaustedResponse(reason), slot: null, exhausted: true, kind: "exhausted" };
|
|
266
421
|
}
|
|
267
422
|
for (const account of candidates) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
} catch (e) {
|
|
280
|
-
markSlotCooldown(account.slot, DEFAULT_COOLDOWN_MS);
|
|
281
|
-
logWarn("cline slot fetch error — cooling slot", { slot: account.slot });
|
|
282
|
-
void e;
|
|
283
|
-
continue;
|
|
423
|
+
// Stale device-login bearer: one refresh before the attempt. A dead grant
|
|
424
|
+
// cools the slot out immediately; a transient fault keeps the stale token.
|
|
425
|
+
let refreshedThisSlot = false;
|
|
426
|
+
if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
|
|
427
|
+
const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
|
|
428
|
+
if (outcome === false) {
|
|
429
|
+
markSlotCooldown(account.slot, AUTH_COOLDOWN_MS);
|
|
430
|
+
logWarn("cline slot refresh rejected — cooling slot", { slot: account.slot });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
refreshedThisSlot = outcome === true;
|
|
284
434
|
}
|
|
285
|
-
const
|
|
286
|
-
|
|
435
|
+
const attempt = async (token: string): Promise<Response | null> => {
|
|
436
|
+
try {
|
|
437
|
+
return await fetchImpl(opts.chatUrl, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: {
|
|
440
|
+
"content-type": "application/json",
|
|
441
|
+
authorization: `Bearer ${token}`,
|
|
442
|
+
},
|
|
443
|
+
body: opts.body,
|
|
444
|
+
});
|
|
445
|
+
} catch (e) {
|
|
446
|
+
markSlotCooldown(account.slot, DEFAULT_COOLDOWN_MS);
|
|
447
|
+
logWarn("cline slot fetch error — cooling slot", { slot: account.slot });
|
|
448
|
+
void e;
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const succeed = (res: Response, kind: ClineErrorKind): ClineRollResult => {
|
|
287
453
|
if (kind === "ok") {
|
|
288
454
|
account.lastOkAt = Date.now();
|
|
289
455
|
pool.activeSlot = account.slot;
|
|
290
456
|
savePool(pool);
|
|
291
457
|
}
|
|
292
458
|
return { res, slot: account.slot, exhausted: false, kind };
|
|
459
|
+
};
|
|
460
|
+
const cool = (kind: ClineErrorKind, status: number): void => {
|
|
461
|
+
markSlotCooldown(
|
|
462
|
+
account.slot,
|
|
463
|
+
kind === "auth" ? AUTH_COOLDOWN_MS : kind === "rate-limit" ? RATE_COOLDOWN_MS : SERVER_COOLDOWN_MS,
|
|
464
|
+
);
|
|
465
|
+
logWarn("cline slot failed — rolling to next slot", { slot: account.slot, status });
|
|
466
|
+
};
|
|
467
|
+
let res = await attempt(account.token);
|
|
468
|
+
if (!res) continue;
|
|
469
|
+
let kind = mapClineError(res.status);
|
|
470
|
+
if (kind === "ok" || kind === "client") return succeed(res, kind);
|
|
471
|
+
// Auth failure on a refreshable slot that has not refreshed yet: one
|
|
472
|
+
// refresh, then exactly one retry with the fresh bearer.
|
|
473
|
+
if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
|
|
474
|
+
try {
|
|
475
|
+
await res.body?.cancel();
|
|
476
|
+
} catch { }
|
|
477
|
+
const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
|
|
478
|
+
if (outcome === true) {
|
|
479
|
+
refreshedThisSlot = true;
|
|
480
|
+
res = await attempt(account.token);
|
|
481
|
+
if (!res) continue;
|
|
482
|
+
kind = mapClineError(res.status);
|
|
483
|
+
if (kind === "ok" || kind === "client") return succeed(res, kind);
|
|
484
|
+
} else if (outcome === false) {
|
|
485
|
+
try {
|
|
486
|
+
await res.body?.cancel();
|
|
487
|
+
} catch { }
|
|
488
|
+
cool(kind, res.status);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
// Transient refresh fault: fall through and cool the slot on the
|
|
492
|
+
// original auth result below.
|
|
293
493
|
}
|
|
294
|
-
|
|
295
|
-
account.slot,
|
|
296
|
-
kind === "auth" ? AUTH_COOLDOWN_MS : kind === "rate-limit" ? RATE_COOLDOWN_MS : SERVER_COOLDOWN_MS,
|
|
297
|
-
);
|
|
494
|
+
cool(kind, res.status);
|
|
298
495
|
try {
|
|
299
496
|
await res.body?.cancel();
|
|
300
497
|
} catch { }
|
|
301
|
-
logWarn("cline slot failed — rolling to next slot", { slot: account.slot, status: res.status });
|
|
302
498
|
}
|
|
303
499
|
logWarn("cline pool exhausted after roll", { slots: candidates.length });
|
|
304
500
|
return {
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cline WorkOS device-flow auth (extension login, no paste).
|
|
3
|
+
* Mirrors C:/tmp/cline-login-proto/src/cline-auth.ts + reference/cline
|
|
4
|
+
* sdk/packages/core/src/auth/cline.ts (device authorize / poll /
|
|
5
|
+
* register / refresh) and provider-auth-registry.ts (expiry derive,
|
|
6
|
+
* workos: prefix). All network goes through injectable fetchImpl;
|
|
7
|
+
* mocked tests only, no live calls here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface OAuthCredentials {
|
|
11
|
+
access: string;
|
|
12
|
+
refresh: string;
|
|
13
|
+
/** Expiration timestamp in milliseconds since epoch. */
|
|
14
|
+
expires: number;
|
|
15
|
+
accountId?: string;
|
|
16
|
+
email?: string;
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DeviceAuth {
|
|
21
|
+
deviceCode: string;
|
|
22
|
+
userCode: string;
|
|
23
|
+
verificationUri: string;
|
|
24
|
+
verificationUriComplete?: string;
|
|
25
|
+
expiresIn: number;
|
|
26
|
+
interval: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DevicePollTokens {
|
|
30
|
+
accessToken: string;
|
|
31
|
+
refreshToken: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PollOptions {
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
maxWaitMs?: number;
|
|
37
|
+
fetchImpl?: FetchImpl;
|
|
38
|
+
/** Cooperative cancel from the login UI (esc / timeout). */
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_WORKOS_BASE = "https://api.workos.com";
|
|
43
|
+
export const DEFAULT_API_BASE = "https://api.cline.bot";
|
|
44
|
+
export const WORKOS_TOKEN_PREFIX = "workos:";
|
|
45
|
+
|
|
46
|
+
/** Env override for the WorkOS client id (wins over the bundled default). */
|
|
47
|
+
export const CLINE_WORKOS_CLIENT_ID_ENV = "PI_FREEFLOW_CLINE_WORKOS_CLIENT_ID";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Bundled production client id (reference shared/src/runtime/cline-environment.ts,
|
|
51
|
+
* production entry). Env override wins when set and non-blank.
|
|
52
|
+
*/
|
|
53
|
+
export const BUNDLED_CLINE_WORKOS_CLIENT_ID = "client_01K3A541FN8TA3EPPHTD2325AR";
|
|
54
|
+
|
|
55
|
+
export function resolveClineWorkosClientId(): string {
|
|
56
|
+
const fromEnv = process.env[CLINE_WORKOS_CLIENT_ID_ENV]?.trim();
|
|
57
|
+
if (fromEnv) return fromEnv;
|
|
58
|
+
return BUNDLED_CLINE_WORKOS_CLIENT_ID;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolved client id used by start/poll. */
|
|
62
|
+
export const CLINE_WORKOS_CLIENT_ID = resolveClineWorkosClientId();
|
|
63
|
+
|
|
64
|
+
export function toApiKey(accessToken: string): string {
|
|
65
|
+
const token = accessToken.trim();
|
|
66
|
+
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX) ? token : `${WORKOS_TOKEN_PREFIX}${token}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEVICE_AUTHORIZATION_PATH = "/user_management/authorize/device";
|
|
70
|
+
const AUTHENTICATE_PATH = "/user_management/authenticate";
|
|
71
|
+
const REGISTER_PATH = "/api/v1/auth/register";
|
|
72
|
+
const REFRESH_PATH = "/api/v1/auth/refresh";
|
|
73
|
+
|
|
74
|
+
const HTTP_TIMEOUT_MS = 30 * 1000;
|
|
75
|
+
|
|
76
|
+
export type FetchImpl = typeof fetch;
|
|
77
|
+
|
|
78
|
+
function joinUrl(base: string, path: string): string {
|
|
79
|
+
return base.replace(/\/+$/, "") + path;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ClineAuthError extends Error {
|
|
83
|
+
readonly status?: number;
|
|
84
|
+
readonly errorCode?: string;
|
|
85
|
+
constructor(message: string, opts?: { status?: number; errorCode?: string }) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "ClineAuthError";
|
|
88
|
+
this.status = opts?.status;
|
|
89
|
+
this.errorCode = opts?.errorCode;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toEpochMs(isoDateTime: string): number {
|
|
94
|
+
const epoch = Date.parse(isoDateTime);
|
|
95
|
+
if (Number.isNaN(epoch)) {
|
|
96
|
+
throw new Error(`Invalid expiresAt value: ${isoDateTime}`);
|
|
97
|
+
}
|
|
98
|
+
return epoch;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toSeconds(value: unknown, fallback: number): number {
|
|
102
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeJwtExpMs(accessToken: string): number | null {
|
|
107
|
+
try {
|
|
108
|
+
const parts = accessToken.split(".");
|
|
109
|
+
if (parts.length < 2) return null;
|
|
110
|
+
const payload = JSON.parse(
|
|
111
|
+
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
|
|
112
|
+
) as { exp?: unknown };
|
|
113
|
+
if (typeof payload.exp === "number" && payload.exp > 0) return payload.exp * 1000;
|
|
114
|
+
return null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Expiry derivation order (per registry deriveCredentialExpiry):
|
|
121
|
+
// explicit server expiry -> JWT exp claim -> already-expired (forces refresh).
|
|
122
|
+
export function deriveExpiry(explicitMs: number | undefined, accessToken: string): number {
|
|
123
|
+
if (typeof explicitMs === "number" && Number.isFinite(explicitMs) && explicitMs > 0) return explicitMs;
|
|
124
|
+
return decodeJwtExpMs(accessToken) ?? Date.now() - 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function startDeviceAuth(
|
|
128
|
+
workosBase: string = DEFAULT_WORKOS_BASE,
|
|
129
|
+
fetchImpl: FetchImpl = fetch,
|
|
130
|
+
): Promise<DeviceAuth> {
|
|
131
|
+
const response = await fetchImpl(joinUrl(workosBase, DEVICE_AUTHORIZATION_PATH), {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
134
|
+
body: new URLSearchParams({ client_id: resolveClineWorkosClientId() }),
|
|
135
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
136
|
+
});
|
|
137
|
+
const json = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new ClineAuthError(
|
|
140
|
+
`Device authorization failed: ${response.status}` +
|
|
141
|
+
(typeof json.error_description === "string" ? ` - ${json.error_description}` : ""),
|
|
142
|
+
{ status: response.status, errorCode: typeof json.error === "string" ? json.error : undefined },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
typeof json.device_code !== "string" ||
|
|
147
|
+
typeof json.user_code !== "string" ||
|
|
148
|
+
typeof json.verification_uri !== "string"
|
|
149
|
+
) {
|
|
150
|
+
throw new Error("Invalid WorkOS device authorization response");
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
deviceCode: json.device_code,
|
|
154
|
+
userCode: json.user_code,
|
|
155
|
+
verificationUri: json.verification_uri,
|
|
156
|
+
verificationUriComplete:
|
|
157
|
+
typeof json.verification_uri_complete === "string" ? json.verification_uri_complete : undefined,
|
|
158
|
+
expiresIn: toSeconds(json.expires_in, 300),
|
|
159
|
+
interval: toSeconds(json.interval, 5),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function throwDenied(payload: Record<string, unknown>, status: number, fallback: string): never {
|
|
164
|
+
const code = typeof payload.error === "string" ? payload.error : undefined;
|
|
165
|
+
const detail = typeof payload.error_description === "string" ? payload.error_description : fallback;
|
|
166
|
+
throw new ClineAuthError(detail, { status, errorCode: code });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function pollDeviceToken(
|
|
170
|
+
workosBase: string = DEFAULT_WORKOS_BASE,
|
|
171
|
+
deviceCode = "",
|
|
172
|
+
intervalSeconds = 5,
|
|
173
|
+
options?: PollOptions,
|
|
174
|
+
): Promise<DevicePollTokens> {
|
|
175
|
+
if (!deviceCode) throw new Error("deviceCode is required");
|
|
176
|
+
const fetchFn = options?.fetchImpl ?? fetch;
|
|
177
|
+
const outerSignal = options?.signal;
|
|
178
|
+
const deadline = Date.now() + (options?.maxWaitMs ?? intervalSeconds * 1000 * 60 * 10);
|
|
179
|
+
let interval = Math.max(1, intervalSeconds);
|
|
180
|
+
for (; ;) {
|
|
181
|
+
if (outerSignal?.aborted) {
|
|
182
|
+
throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
|
|
183
|
+
}
|
|
184
|
+
let response: Response;
|
|
185
|
+
try {
|
|
186
|
+
response = await fetchFn(joinUrl(workosBase, AUTHENTICATE_PATH), {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
189
|
+
body: new URLSearchParams({
|
|
190
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
191
|
+
device_code: deviceCode,
|
|
192
|
+
client_id: resolveClineWorkosClientId(),
|
|
193
|
+
}),
|
|
194
|
+
signal: AbortSignal.timeout(options?.timeoutMs ?? HTTP_TIMEOUT_MS),
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (outerSignal?.aborted) {
|
|
198
|
+
throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
|
|
199
|
+
}
|
|
200
|
+
if (error instanceof ClineAuthError) throw error;
|
|
201
|
+
if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
|
|
202
|
+
await sleepMs(interval * 1000, outerSignal);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
206
|
+
if (response.ok) {
|
|
207
|
+
if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
|
|
208
|
+
throw new Error("Invalid WorkOS token response");
|
|
209
|
+
}
|
|
210
|
+
return { accessToken: payload.access_token, refreshToken: payload.refresh_token };
|
|
211
|
+
}
|
|
212
|
+
if (payload.error === "authorization_pending") {
|
|
213
|
+
// keep polling at the current interval
|
|
214
|
+
} else if (payload.error === "slow_down") {
|
|
215
|
+
interval += 1;
|
|
216
|
+
} else if (
|
|
217
|
+
payload.error === "access_denied" ||
|
|
218
|
+
payload.error === "expired_token" ||
|
|
219
|
+
payload.error === "invalid_grant"
|
|
220
|
+
) {
|
|
221
|
+
throwDenied(payload, response.status, `Device login refused (${String(payload.error)})`);
|
|
222
|
+
} else {
|
|
223
|
+
throw new ClineAuthError(
|
|
224
|
+
typeof payload.error_description === "string"
|
|
225
|
+
? String(payload.error_description)
|
|
226
|
+
: `WorkOS token polling failed: ${response.status}`,
|
|
227
|
+
{ status: response.status, errorCode: typeof payload.error === "string" ? payload.error : undefined },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
|
|
231
|
+
await sleepMs(interval * 1000, outerSignal);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
|
|
236
|
+
if (signal?.aborted) throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
|
|
237
|
+
if (!signal) {
|
|
238
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
239
|
+
setTimeout(resolve, ms);
|
|
240
|
+
await promise;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
244
|
+
const onAbort = (): void => {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
reject(new ClineAuthError("Device login cancelled", { errorCode: "cancelled" }));
|
|
247
|
+
};
|
|
248
|
+
const timer = setTimeout(() => {
|
|
249
|
+
signal.removeEventListener("abort", onAbort);
|
|
250
|
+
resolve();
|
|
251
|
+
}, ms);
|
|
252
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
253
|
+
await promise;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface ClineTokenData {
|
|
257
|
+
accessToken: string;
|
|
258
|
+
refreshToken?: string;
|
|
259
|
+
tokenType: string;
|
|
260
|
+
expiresAt: string;
|
|
261
|
+
userInfo: { email: string; clineUserId: string | null };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCredentials {
|
|
265
|
+
const refresh = data.refreshToken ?? fallbackRefresh;
|
|
266
|
+
if (!refresh) throw new Error("Token response did not include a refresh token");
|
|
267
|
+
let explicit: number | undefined;
|
|
268
|
+
try {
|
|
269
|
+
explicit = toEpochMs(data.expiresAt);
|
|
270
|
+
} catch {
|
|
271
|
+
explicit = undefined;
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
access: data.accessToken,
|
|
275
|
+
refresh,
|
|
276
|
+
expires: deriveExpiry(explicit, data.accessToken),
|
|
277
|
+
accountId: data.userInfo.clineUserId ?? undefined,
|
|
278
|
+
email: data.userInfo.email || undefined,
|
|
279
|
+
metadata: { tokenType: data.tokenType },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function readTokenData(response: Response, message: string): Promise<ClineTokenData> {
|
|
284
|
+
const json = (await response.json().catch(() => ({}))) as {
|
|
285
|
+
success?: boolean;
|
|
286
|
+
data?: ClineTokenData;
|
|
287
|
+
};
|
|
288
|
+
if (!json.success || !json.data?.accessToken) {
|
|
289
|
+
throw new Error(`Invalid token response: ${message}`);
|
|
290
|
+
}
|
|
291
|
+
return json.data;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function registerClineToken(
|
|
295
|
+
apiBase: string = DEFAULT_API_BASE,
|
|
296
|
+
accessToken = "",
|
|
297
|
+
refreshToken = "",
|
|
298
|
+
fetchImpl: FetchImpl = fetch,
|
|
299
|
+
): Promise<OAuthCredentials> {
|
|
300
|
+
if (!accessToken || !refreshToken) throw new Error("accessToken and refreshToken are required");
|
|
301
|
+
const response = await fetchImpl(joinUrl(apiBase, REGISTER_PATH), {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "Content-Type": "application/json" },
|
|
304
|
+
body: JSON.stringify({ accessToken, refreshToken }),
|
|
305
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
306
|
+
});
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
const text = await response.text().catch(() => "");
|
|
309
|
+
throw new ClineAuthError(`Token registration failed: ${response.status}${text ? ` - ${text}` : ""}`, {
|
|
310
|
+
status: response.status,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return toCredentials(await readTokenData(response, "register"));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function refreshClineToken(
|
|
317
|
+
apiBase: string = DEFAULT_API_BASE,
|
|
318
|
+
refreshToken = "",
|
|
319
|
+
fetchImpl: FetchImpl = fetch,
|
|
320
|
+
): Promise<OAuthCredentials> {
|
|
321
|
+
if (!refreshToken) throw new Error("refreshToken is required");
|
|
322
|
+
const response = await fetchImpl(joinUrl(apiBase, REFRESH_PATH), {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: { "Content-Type": "application/json" },
|
|
325
|
+
body: JSON.stringify({ refreshToken, grantType: "refresh_token" }),
|
|
326
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
327
|
+
});
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
const text = await response.text().catch(() => "");
|
|
330
|
+
throw new ClineAuthError(`Token refresh failed: ${response.status}${text ? ` - ${text}` : ""}`, {
|
|
331
|
+
status: response.status,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return toCredentials(await readTokenData(response, "refresh"), refreshToken);
|
|
335
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -59,6 +59,8 @@ import {
|
|
|
59
59
|
formatRelayStatusLabel,
|
|
60
60
|
} from "./relay-state.ts";
|
|
61
61
|
import { addAccount, isClineSlotHealthy, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
|
|
62
|
+
import type { ClinePoolState } from "./cline-accounts.ts";
|
|
63
|
+
import { pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
|
|
62
64
|
import type {
|
|
63
65
|
ExtensionAPI,
|
|
64
66
|
ExtensionContext,
|
|
@@ -348,20 +350,45 @@ function startLogsFollow(
|
|
|
348
350
|
logsFollowTimer.unref?.();
|
|
349
351
|
}
|
|
350
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Pick the login slot when the user gives none: "default" for the first
|
|
355
|
+
* login, then the smallest free "slot-2", "slot-3", … — never overwriting
|
|
356
|
+
* a saved login. Pure — takes the pool, returns a name.
|
|
357
|
+
*/
|
|
358
|
+
function nextClineSlot(pool: ClinePoolState): string {
|
|
359
|
+
if (!pool.accounts.some((a) => a.slot === "default")) return "default";
|
|
360
|
+
let n = 2;
|
|
361
|
+
while (pool.accounts.some((a) => a.slot === `slot-${n}`)) n += 1;
|
|
362
|
+
return `slot-${n}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** One notify-ready line per saved login, in pool order. */
|
|
366
|
+
function formatClineAccountLines(pool: ClinePoolState): string[] {
|
|
367
|
+
return pool.accounts.map((a, idx) => {
|
|
368
|
+
const star = a.slot === pool.activeSlot ? "*" : " ";
|
|
369
|
+
const health = isClineSlotHealthy(a.slot) ? "ready" : "cooling";
|
|
370
|
+
return `${star} [${idx + 1}] [${a.slot}] key ending ${redactedToken(a.token)} — ${health}`;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/** User-safe one-line message for caught values (never echoes secrets). */
|
|
374
|
+
function clineErrorMessage(e: unknown): string {
|
|
375
|
+
if (e instanceof Error && e.message) return e.message;
|
|
376
|
+
if (typeof e === "string" && e) return e;
|
|
377
|
+
return "unknown error";
|
|
378
|
+
}
|
|
351
379
|
export function createCommandSpec(
|
|
352
380
|
_pi: ExtensionAPI,
|
|
353
381
|
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
354
382
|
): Omit<RegisteredCommand, "name"> {
|
|
355
383
|
return {
|
|
356
384
|
description:
|
|
357
|
-
"Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno | install-startup | uninstall-startup | cline login [slot] | cline accounts | cline logout [slot]",
|
|
385
|
+
"Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot]",
|
|
358
386
|
getArgumentCompletions: (prefix: string) =>
|
|
359
387
|
[
|
|
360
388
|
"auto",
|
|
361
389
|
"on",
|
|
362
390
|
"off",
|
|
363
391
|
"hide",
|
|
364
|
-
"show",
|
|
365
392
|
"widget hide",
|
|
366
393
|
"widget show",
|
|
367
394
|
"status",
|
|
@@ -384,6 +411,7 @@ export function createCommandSpec(
|
|
|
384
411
|
"uninstall-startup",
|
|
385
412
|
"cline",
|
|
386
413
|
"cline login",
|
|
414
|
+
"cline login --key",
|
|
387
415
|
"cline accounts",
|
|
388
416
|
"cline logout",
|
|
389
417
|
"refresh",
|
|
@@ -1399,17 +1427,72 @@ export function createCommandSpec(
|
|
|
1399
1427
|
const action = (tokens[0] || "accounts").toLowerCase();
|
|
1400
1428
|
const arg = tokens.slice(1).join(" ").trim();
|
|
1401
1429
|
if (action === "login") {
|
|
1402
|
-
const
|
|
1403
|
-
const
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1430
|
+
const raw = tokens.slice(1);
|
|
1431
|
+
const pasteMode = raw.some((t) => t === "--key");
|
|
1432
|
+
const slotArg = raw.filter((t) => t !== "--key").join(" ").trim();
|
|
1433
|
+
if (pasteMode) {
|
|
1434
|
+
// Manual fallback: paste a workos: API key (never shown back).
|
|
1435
|
+
const slot = slotArg || ((await ctx.ui.input("Cline slot name (empty = default):", "default"))?.trim() || "default");
|
|
1436
|
+
const token = ((await ctx.ui.input(`Paste the API key for Cline slot [${slot}]:`, ""))?.trim() || "");
|
|
1437
|
+
if (!token) {
|
|
1438
|
+
ctx.ui.notify("Cancelled — no API key provided", "warning");
|
|
1439
|
+
} else {
|
|
1440
|
+
try {
|
|
1441
|
+
addAccount(slot, token);
|
|
1442
|
+
ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(token)}) — free Cline models are ready to use`, "info");
|
|
1443
|
+
} catch (e) {
|
|
1444
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
// Device login inside the extension: approve in the browser, no paste.
|
|
1450
|
+
const slot = slotArg || nextClineSlot(loadPool());
|
|
1451
|
+
let started;
|
|
1452
|
+
try {
|
|
1453
|
+
started = await startDeviceAuth();
|
|
1454
|
+
} catch (e) {
|
|
1455
|
+
ctx.ui.notify(`Could not reach Cline to start login: ${clineErrorMessage(e)}`, "error");
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const link = started.verificationUriComplete ?? started.verificationUri;
|
|
1459
|
+
const minutes = Math.max(1, Math.round(started.expiresIn / 60));
|
|
1460
|
+
ctx.ui.notify(`Cline login [${slot}]: open ${link} and enter code ${started.userCode} (expires in ~${minutes} min)`, "info");
|
|
1461
|
+
ctx.ui.notify("Waiting for approval in the browser — approve or cancel there; this finishes on its own.", "info");
|
|
1462
|
+
const progress = setInterval(() => {
|
|
1463
|
+
ctx.ui.notify("Still waiting for Cline approval — approve or cancel in the browser.", "info");
|
|
1464
|
+
}, 45000);
|
|
1465
|
+
// @ts-ignore allow unref to not block process exit in CLI
|
|
1466
|
+
progress.unref?.();
|
|
1467
|
+
try {
|
|
1468
|
+
const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval);
|
|
1469
|
+
const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
|
|
1470
|
+
const apiKey = toApiKey(creds.access);
|
|
1471
|
+
addAccount(slot, apiKey, {
|
|
1472
|
+
refreshToken: creds.refresh,
|
|
1473
|
+
expiresAt: creds.expires,
|
|
1474
|
+
...(creds.accountId ? { accountId: creds.accountId } : {}),
|
|
1475
|
+
...(creds.email ? { email: creds.email } : {}),
|
|
1476
|
+
});
|
|
1477
|
+
ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(apiKey)}) — free Cline models are ready to use`, "info");
|
|
1478
|
+
const pool = loadPool();
|
|
1479
|
+
ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
|
|
1480
|
+
} catch (e) {
|
|
1481
|
+
let code: string | undefined;
|
|
1482
|
+
if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
|
|
1483
|
+
code = e.errorCode;
|
|
1484
|
+
}
|
|
1485
|
+
if (code === "authorization_pending") {
|
|
1486
|
+
ctx.ui.notify("Still waiting for Cline approval — approve or cancel in the browser.", "info");
|
|
1487
|
+
} else if (code === "access_denied" || code === "cancelled") {
|
|
1488
|
+
ctx.ui.notify("Cline login cancelled.", "warning");
|
|
1489
|
+
} else if (code === "expired_token") {
|
|
1490
|
+
ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
|
|
1491
|
+
} else {
|
|
1492
|
+
ctx.ui.notify(`Cline login did not finish: ${clineErrorMessage(e)}`, "warning");
|
|
1412
1493
|
}
|
|
1494
|
+
} finally {
|
|
1495
|
+
clearInterval(progress);
|
|
1413
1496
|
}
|
|
1414
1497
|
} else if (action === "accounts" || action === "list") {
|
|
1415
1498
|
const pool = loadPool();
|
|
@@ -1433,7 +1516,7 @@ export function createCommandSpec(
|
|
|
1433
1516
|
ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
|
|
1434
1517
|
}
|
|
1435
1518
|
} else {
|
|
1436
|
-
ctx.ui.notify("Usage: /freeflow cline login [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
|
|
1519
|
+
ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
|
|
1437
1520
|
}
|
|
1438
1521
|
} else if (sub === "export") {
|
|
1439
1522
|
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|