@magpie-community/opencode-cline-auth 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +67 -2
  2. package/index.mjs +891 -0
  3. package/package.json +9 -4
package/README.md CHANGED
@@ -1,3 +1,68 @@
1
- # Temporary Holding Version
1
+ # @magpie-community/opencode-cline-auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Cline (cline.bot) on your own Cline account: the models Cline's gateway
4
+ serves — usage-billed models, ClinePass's and Cline's free models alike.
5
+ Provider id: `cline`.
6
+
7
+ ## Signing in
8
+
9
+ - **Cline device sign-in** — the flow Cline's own client runs: the plugin asks
10
+ WorkOS for a device code and opens the approval page, polls until you
11
+ approve, then trades the WorkOS tokens at Cline's `/api/v1/auth/register`
12
+ for the account's own token pair.
13
+ - **Cline API key** — create one at [app.cline.bot → Settings → API Keys](https://app.cline.bot/dashboard/account)
14
+ and paste it. Sent as a plain Bearer key.
15
+
16
+ The sign-in is kept where OpenCode keeps auth (`auth.json` under the data
17
+ directory); magpie keeps plugin sign-ins in its own store
18
+ (`~/.config/magpie/plugin-auth.json`). Access tokens are renewed through
19
+ `/api/v1/auth/refresh` when they near expiry — Cline rotates the refresh
20
+ token, and the rotated pair is saved back the moment it arrives (and kept in
21
+ memory beside the store, so a save that fails can't leave the next request
22
+ spending the token that was already spent). Refreshes are serialized, so two
23
+ requests renewing at once can't spend one token and lose the other. A refresh
24
+ that fails for a while keeps the token that is still good, as Cline's own
25
+ client does; only a token that has actually expired is treated as the sign-in
26
+ gone.
27
+
28
+ ## Requests
29
+
30
+ Chat completions go to `https://api.cline.bot/api/v1/chat/completions` in
31
+ OpenAI's format (streaming included), with `Authorization: Bearer
32
+ workos:<access token>` for a signed-in account or the raw API key, and the
33
+ full client-identity header set Cline's own clients send (`HTTP-Referer`,
34
+ `X-Title`, `User-Agent: Cline/<version>`, `X-CLIENT-TYPE: cline-cli`,
35
+ `X-CLIENT-VERSION`, `X-PLATFORM`, `X-PLATFORM-VERSION`, `X-CORE-VERSION`,
36
+ `X-IS-MULTIROOT`, and a per-task `X-Task-ID`) — pinned to Cline's current CLI
37
+ release, which is also what the free models' gate reads; when Cline ships a
38
+ new release, move the pinned numbers in `index.mjs` along. Auth and account
39
+ requests carry the same identity. A finished non-streaming answer rides in a
40
+ `{"data": …}` envelope the official clients take apart; the plugin takes it
41
+ apart too. The model list is read from
42
+ `/api/v1/ai/cline/recommended-models` and `/api/v1/ai/cline/models` (neither
43
+ needs a sign-in); usage reads `/api/v1/users/me` and the account's `/balance`
44
+ (its balance is counted in millionths of a dollar).
45
+
46
+ ## Models
47
+
48
+ The list is Cline's own, merged from the two feeds its clients read: the
49
+ recommended-models feed (its recommended picks, its free models and
50
+ ClinePass's) and the whole cloud catalog (`/ai/cline/models`, where the
51
+ usage-billed models the recommended feed doesn't name live). Refreshed from
52
+ the live feeds whenever they answer; until then a bundled snapshot of the
53
+ recommended list, Cline's own default (`anthropic/claude-sonnet-5`) among it.
54
+ Cline's `clineCloud` group is left out, as Cline's own clients leave it out
55
+ unless they opt in.
56
+
57
+ - The **free** group (`cline-free/…`, `stealth/…`) costs nothing — the names
58
+ say "(free)". They are gated on the client surface a request claims; the
59
+ plugin claims what it is, and the gate takes it. If Cline ever narrows that
60
+ gate to its own apps, those models go away.
61
+ - The **ClinePass** group (`cline-pass/…`) needs a ClinePass subscription —
62
+ the names say so, and asking without one answers 403.
63
+ - The rest are usage-billed against the account's Credits balance.
64
+
65
+ ## Not included
66
+
67
+ - Cline's MCP and web-search endpoints — a chat provider only.
68
+ - Team/organization account switching.
package/index.mjs ADDED
@@ -0,0 +1,891 @@
1
+ // Cline (cline.bot) as a magpie / OpenCode provider plugin.
2
+ //
3
+ // The same sign-in Cline's own client runs — a WorkOS device code the browser
4
+ // approves, traded at Cline's register endpoint for the account's token pair —
5
+ // or a plain API key from app.cline.bot. Chat completions go to Cline's
6
+ // gateway in OpenAI's format; the model list comes from its recommended-models
7
+ // and cloud-models feeds; usage reads the account's credit balance.
8
+ import { STATUS_CODES } from "node:http"
9
+ import { randomUUID } from "node:crypto"
10
+
11
+ const PROVIDER = "cline"
12
+ const API = "https://api.cline.bot/api/v1"
13
+
14
+ // the WorkOS client Cline's own apps sign in with (their source, production)
15
+ const WORKOS_CLIENT = "client_01K3A541FN8TA3EPPHTD2325AR"
16
+ const WORKOS_DEVICE = "https://api.workos.com/user_management/authorize/device"
17
+ const WORKOS_TOKEN = "https://api.workos.com/user_management/authenticate"
18
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
19
+
20
+ // the gateway wants the account's token told apart from a raw API key: a
21
+ // signed-in one rides as "workos:<jwt>" (Cline's own clients send it so)
22
+ const WORKOS_PREFIX = "workos:"
23
+
24
+ const REFRESH_LEAD = 5 * 60 * 1000
25
+ // a refresh that failed transiently keeps a token with this much left, as
26
+ // Cline's getValidClineCredentials does (DEFAULT_RETRYABLE_TOKEN_GRACE_MS);
27
+ // only once it has actually expired is the sign-in the one to blame
28
+ const RETRYABLE_GRACE = 30 * 1000
29
+ const DEVICE_CAP = 600 // seconds the browser may take, however long the code lives
30
+
31
+ // CLIENT is who every request says it comes from: the same client surface
32
+ // Cline's own CLI runs as, headers down (apps/cli registers exactly this
33
+ // identity, and the free models' gate reads these). Pinned to Cline's current
34
+ // release — when they ship a new one, move these along (the numbers are the
35
+ // only thing that has to match).
36
+ const CLIENT = { type: "cline-cli", version: "3.0.68", platform: "cli", core: "0.0.90" }
37
+
38
+ // clientHeaders is the header set resolveProviderRequestHeaders builds for a
39
+ // client with that identity; a task id rides per chat request, as the
40
+ // official clients ride one per task
41
+ function clientHeaders(taskId) {
42
+ return {
43
+ "HTTP-Referer": "https://cline.bot",
44
+ "X-Title": "Cline",
45
+ "User-Agent": `Cline/${CLIENT.version}`,
46
+ "X-IS-MULTIROOT": "false",
47
+ "X-CLIENT-TYPE": CLIENT.type,
48
+ "X-CLIENT-VERSION": CLIENT.version,
49
+ "X-PLATFORM": CLIENT.platform,
50
+ "X-PLATFORM-VERSION": CLIENT.version,
51
+ "X-CORE-VERSION": CLIENT.core,
52
+ ...(taskId ? { "X-Task-ID": taskId } : {}),
53
+ }
54
+ }
55
+
56
+ // ---- small helpers ------------------------------------------------------------
57
+
58
+ const firstOf = (...vs) => vs.find((v) => typeof v === "string" && v.trim())?.trim() ?? ""
59
+
60
+ // compactNumber is a whole number bare, else two decimals with the trailing
61
+ // zeroes off (magpie's subscription_usage.go)
62
+ function compactNumber(n) {
63
+ if (!Number.isFinite(n)) return "0"
64
+ if (n === Math.trunc(n)) return String(Math.trunc(n))
65
+ return String(Number(n.toFixed(2)))
66
+ }
67
+
68
+ // toMs is an expiry Cline sends as an ISO datetime; one it didn't send (or one
69
+ // that won't parse) is taken as an hour out rather than as "never"
70
+ function toMs(v) {
71
+ const t = typeof v === "string" ? Date.parse(v) : Number(v)
72
+ if (Number.isFinite(t) && t > Date.now()) return t
73
+ return Date.now() + 60 * 60 * 1000
74
+ }
75
+
76
+ // timeOf reads a reset time: epoch seconds, epoch ms, or a date string
77
+ function timeOf(m, keys) {
78
+ for (const k of keys) {
79
+ const v = m?.[k]
80
+ if (typeof v === "number" && v > 1e12) return new Date(v).toISOString()
81
+ if (typeof v === "number" && v > 1e9) return new Date(v * 1000).toISOString()
82
+ if (typeof v === "string") {
83
+ const t = Date.parse(v)
84
+ if (!Number.isNaN(t)) return new Date(t).toISOString()
85
+ }
86
+ }
87
+ return ""
88
+ }
89
+
90
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
91
+
92
+ // ---- the sign-in as it is kept --------------------------------------------------
93
+
94
+ // authOf reads a sign-in as magpie keeps it: OpenCode's type field, and what
95
+ // this plugin needs beside it to renew it later
96
+ const authOf = (a) => ({
97
+ type: a?.type === "api" ? "api" : "oauth",
98
+ access: typeof a?.access === "string" ? a.access : "",
99
+ refresh: typeof a?.refresh === "string" ? a.refresh : "",
100
+ expires: Number(a?.expires) || 0,
101
+ key: typeof a?.key === "string" ? a.key : "",
102
+ uid: typeof a?.uid === "string" ? a.uid : "",
103
+ email: typeof a?.email === "string" ? a.email : "",
104
+ name: typeof a?.name === "string" ? a.name : "",
105
+ accountId: typeof a?.accountId === "string" ? a.accountId : "",
106
+ })
107
+
108
+ // bearerOf is what an Authorization header carries: a signed-in account's
109
+ // access token with Cline's routing prefix, or the raw API key
110
+ function bearerOf(a) {
111
+ if (a?.type === "api") return a.key
112
+ const access = typeof a?.access === "string" ? a.access : ""
113
+ if (!access) return ""
114
+ // the prefix is matched the way Cline's own client matches it, whatever case
115
+ return access.toLowerCase().startsWith(WORKOS_PREFIX) ? access : WORKOS_PREFIX + access
116
+ }
117
+
118
+ // parseAuth reads Cline's token answer ({accessToken, refreshToken, expiresAt,
119
+ // userInfo}) into what this plugin keeps, with a refresh to fall back on when
120
+ // the answer didn't rotate it
121
+ function parseAuth(data, fallbackRefresh = "") {
122
+ const access = typeof data?.accessToken === "string" ? data.accessToken : ""
123
+ if (!access) return { error: "Cline sent back no access token" }
124
+ const refresh = firstOf(data?.refreshToken, fallbackRefresh)
125
+ if (!refresh) return { error: "Cline sent back no refresh token" }
126
+ const ui = data?.userInfo && typeof data.userInfo === "object" ? data.userInfo : {}
127
+ const uid = firstOf(ui.clineUserId, ui.subject)
128
+ const email = firstOf(ui.email)
129
+ return {
130
+ access,
131
+ refresh,
132
+ expires: toMs(data?.expiresAt),
133
+ uid,
134
+ email,
135
+ name: firstOf(ui.name),
136
+ accountId: firstOf(email, uid),
137
+ }
138
+ }
139
+
140
+ // ---- talking to Cline ------------------------------------------------------------
141
+
142
+ // Lapsed is the sign-in itself gone: a refused refresh, or an API that turned
143
+ // the account's token away. magpie marks the account from e.expired.
144
+ class Lapsed extends Error {
145
+ constructor(message) {
146
+ super(message)
147
+ this.expired = true
148
+ }
149
+ }
150
+
151
+ // clineApi is one call to Cline's own API, and the {success, data} envelope it
152
+ // answers in. lapsed says which answers mean the sign-in itself is gone (401
153
+ // and 403, unless the caller knows this endpoint refuses otherwise); the rest
154
+ // is a request that failed.
155
+ async function clineApi(url, { method = "GET", body, headers = {}, signal, lapsed } = {}) {
156
+ const gone = lapsed ?? ((status) => status === 401 || status === 403)
157
+ let res
158
+ try {
159
+ res = await fetch(url, {
160
+ method,
161
+ headers: {
162
+ Accept: "application/json",
163
+ ...clientHeaders(),
164
+ ...headers,
165
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
166
+ },
167
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
168
+ signal: signal ?? AbortSignal.timeout(15_000),
169
+ })
170
+ } catch (e) {
171
+ throw new Error(`Cline: ${e?.message ?? e}`)
172
+ }
173
+ const text = (await res.text().catch(() => "")).trim()
174
+ if (gone(res.status, text))
175
+ throw new Lapsed(`the sign-in lapsed — Cline refused it (HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}); sign in to Cline again`)
176
+ if (!res.ok) throw new Error(`Cline request failed (HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ""})`)
177
+ const env = safeJson(text)
178
+ if (env && typeof env === "object" && "success" in env) {
179
+ if (!env.success) throw new Error(`Cline request failed${env.error ? `: ${env.error}` : ""}`)
180
+ return env.data
181
+ }
182
+ return env
183
+ }
184
+
185
+ // safeJson is text as JSON, or null
186
+ const safeJson = (text) => {
187
+ try {
188
+ return JSON.parse(text)
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ // refresh trades the refresh token for a new pair; Cline rotates it, so the
195
+ // caller saves what comes back. A dead token is refused as 400 invalid_grant
196
+ // (WorkOS's wording rides in the body), not 401 — that refusal is the sign-in
197
+ // gone, anything else is worth a retry later.
198
+ async function refresh(a) {
199
+ if (!a.refresh) throw new Lapsed("Cline's refresh token is gone; sign in to Cline again")
200
+ const data = await clineApi(`${API}/auth/refresh`, {
201
+ method: "POST",
202
+ body: { refreshToken: a.refresh, grantType: "refresh_token" },
203
+ lapsed: (status, text) => [400, 401, 403].includes(status) && /invalid|expired|revoked|unauthorized/i.test(text),
204
+ })
205
+ const p = parseAuth(data, a.refresh)
206
+ if (p.error) throw new Error(`Cline token refresh: ${p.error}`)
207
+ return p
208
+ }
209
+
210
+ // ---- the device sign-in ----------------------------------------------------------
211
+
212
+ // deviceAuthorize starts the flow: a code to approve in the browser
213
+ async function deviceAuthorize() {
214
+ let res
215
+ try {
216
+ res = await fetch(WORKOS_DEVICE, {
217
+ method: "POST",
218
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
219
+ body: new URLSearchParams({ client_id: WORKOS_CLIENT }).toString(),
220
+ signal: AbortSignal.timeout(15_000),
221
+ })
222
+ } catch (e) {
223
+ throw new Error(`Cline sign-in: ${e?.message ?? e}`)
224
+ }
225
+ if (!res.ok) throw new Error(`Cline sign-in: the device code request failed (HTTP ${res.status})`)
226
+ const d = await res.json().catch(() => null)
227
+ if (!d?.device_code || !d?.verification_uri) throw new Error("Cline sign-in: the device code answer was incomplete")
228
+ return d
229
+ }
230
+
231
+ // pollDevice waits the device code out, as Cline's pollWorkOSTokens does: the
232
+ // interval only grows (slow_down raises it for every later poll, RFC 8628), it
233
+ // starts at a second, and a 5xx, a non-JSON reply or a network error is a poll
234
+ // to try again, not the sign-in failing.
235
+ async function pollDevice(d, { fetchImpl = fetch, sleep: sleeper = sleep, now = Date.now } = {}) {
236
+ const deadline = now() + Math.min(Number(d?.expires_in) > 0 ? Number(d.expires_in) : 300, DEVICE_CAP) * 1000
237
+ let interval = Math.max(1, Number(d?.interval) > 0 ? Math.floor(Number(d.interval)) : 5)
238
+ while (now() < deadline) {
239
+ await sleeper(interval * 1000)
240
+ let res = null
241
+ let j = null
242
+ try {
243
+ res = await fetchImpl(WORKOS_TOKEN, {
244
+ method: "POST",
245
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
246
+ body: new URLSearchParams({ grant_type: DEVICE_GRANT, device_code: d.device_code, client_id: WORKOS_CLIENT }).toString(),
247
+ signal: AbortSignal.timeout(15_000),
248
+ })
249
+ j = await res.json().catch(() => null)
250
+ } catch {}
251
+ // WorkOS having a moment, or the network: not the sign-in
252
+ if (!res || res.status >= 500) continue
253
+ // a reply that isn't JSON and isn't a 5xx is the poll turned away: an
254
+ // HTML 400 would otherwise keep polling to the deadline (ten minutes)
255
+ // and fail there anyway
256
+ if (j === null) {
257
+ if (res.status >= 400) throw new Error(`Cline sign-in: the token poll was refused (HTTP ${res.status})`)
258
+ continue
259
+ }
260
+ if (j.error === "authorization_pending") continue
261
+ if (j.error === "slow_down") {
262
+ interval += 1
263
+ continue
264
+ }
265
+ if (j.error) throw new Error(`Cline sign-in: ${j.error}${j.error_description ? ` (${j.error_description})` : ""}`)
266
+ if (typeof j.access_token !== "string" || !j.access_token) throw new Error("Cline sign-in: the token answer had no access token")
267
+ return j
268
+ }
269
+ throw new Error("the sign-in wasn't finished in time")
270
+ }
271
+
272
+ // deviceSignIn is the browser method: the device code page opened, WorkOS
273
+ // polled until the code is approved, and the WorkOS tokens traded at Cline's
274
+ // register endpoint for the account's own pair
275
+ async function deviceSignIn() {
276
+ const d = await deviceAuthorize()
277
+ return {
278
+ url: firstOf(d.verification_uri_complete, d.verification_uri, "https://app.cline.bot"),
279
+ instructions: `Approve the sign-in in the browser${d.user_code ? ` — the code is ${d.user_code}` : ""}.`,
280
+ method: "auto",
281
+ async callback() {
282
+ let j
283
+ try {
284
+ j = await pollDevice(d)
285
+ } catch (e) {
286
+ return { type: "failed", error: e?.message ?? String(e) }
287
+ }
288
+ try {
289
+ const data = await clineApi(`${API}/auth/register`, { method: "POST", body: { accessToken: j.access_token, refreshToken: j.refresh_token ?? "" }, signal: AbortSignal.timeout(30_000) })
290
+ const p = parseAuth(data, j.refresh_token ?? "")
291
+ if (p.error) throw new Error(`Cline sign-in: ${p.error}`)
292
+ return { type: "success", ...p }
293
+ } catch (e) {
294
+ return { type: "failed", error: e?.message ?? String(e) }
295
+ }
296
+ },
297
+ }
298
+ }
299
+
300
+ // ---- models ----------------------------------------------------------------------
301
+
302
+ // CAPS is the shorthand a model id spells in capitals, MIXED the ones with a
303
+ // capital of their own
304
+ const CAPS = new Set(["gpt", "glm", "ai", "api", "llm", "k3"])
305
+ const MIXED = { mimo: "MiMo" }
306
+
307
+ // prettify is a model id as a name: anthropic/claude-sonnet-5.5 becomes
308
+ // "Claude Sonnet 5.5"
309
+ function prettify(id) {
310
+ const seg = String(id).split("/").pop() ?? ""
311
+ return seg
312
+ .split(/[-_]/)
313
+ .filter(Boolean)
314
+ .map((w) => MIXED[w.toLowerCase()] ?? (CAPS.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()))
315
+ .join(" ")
316
+ }
317
+
318
+ // DEFAULT_MODELS is the list before Cline's feed answered: the bundled
319
+ // snapshot of Cline's own recommended-models feed — its recommended models,
320
+ // its free ones and ClinePass's (cline-recommended.generated.ts). The first is
321
+ // also Cline's own default.
322
+ const DEFAULT_MODELS = [
323
+ { id: "anthropic/claude-sonnet-5.5" },
324
+ { id: "anthropic/claude-opus-5.5" },
325
+ { id: "anthropic/claude-sonnet-5" },
326
+ { id: "openai/gpt-6-astra" },
327
+ { id: "openai/gpt-6.1-sol" },
328
+ { id: "spacexai/grok-4.7" },
329
+ { id: "moonshotai/kimi-k3" },
330
+ { id: "cline-free/deepseek-v4.1-flash", free: true },
331
+ { id: "stealth/space-bunny-alpha", free: true },
332
+ { id: "cline-free/mimo-v2.6-flash", free: true },
333
+ { id: "cline-free/muse-spark-1.3-contributor", free: true },
334
+ { id: "cline-pass/deepseek-v4.1-flash" },
335
+ { id: "cline-pass/mimo-v2.6-flash" },
336
+ { id: "cline-pass/mimo-v2.6-pro" },
337
+ { id: "cline-pass/glm-5.3" },
338
+ { id: "cline-pass/deepseek-v4-pro" },
339
+ { id: "cline-pass/qwen3.8-max" },
340
+ { id: "cline-pass/muse-spark-1.3-contributor" },
341
+ { id: "cline-pass/kimi-k3" },
342
+ { id: "cline-pass/glm-5.3-flash" },
343
+ { id: "cline-pass/qwen3.7-max" },
344
+ { id: "cline-pass/qwen3.7-plus" },
345
+ { id: "cline-pass/minimax-m3" },
346
+ { id: "cline-pass/mimo-v2.5-pro" },
347
+ { id: "cline-pass/mimo-v2.5" },
348
+ ].map((m) => ({ name: prettify(m.id) + (m.free ? " (free)" : m.id.startsWith("cline-pass/") ? " (ClinePass)" : ""), ...m }))
349
+
350
+ const MODEL_ID = /^[a-z0-9][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*$/i
351
+
352
+ // FEED_GROUPS is the recommended-models answer's shape, as Cline's own client
353
+ // reads it (cline-recommended.generated.ts): four arrays, and nothing else.
354
+ // clineCloud rides only under the client's includeClineCloudModels, so it is
355
+ // left out here the way the client leaves it out by default.
356
+ const FEED_GROUPS = ["recommended", "free", "clinePass"]
357
+
358
+ // parseFeed reads the recommended-models answer: the four arrays by name, not
359
+ // every value in the object (a description or tag is not a model). A model the
360
+ // feed lists twice keeps the stronger gate, so one in both recommended and
361
+ // free keeps its "(free)" mark.
362
+ function parseFeed(v) {
363
+ const payload = v && typeof v === "object" && !Array.isArray(v) ? v : {}
364
+ const by = new Map()
365
+ // entry is one model: an id bare or in an object, named for the plan gate a
366
+ // picker should show. A feed name that is just the id (or its last part) is
367
+ // the raw id, not a name, so it is prettified like the bundled list's.
368
+ const entry = (e, group) => {
369
+ const o = e && typeof e === "object" && !Array.isArray(e) ? e : {}
370
+ const raw = typeof e === "string" ? e.trim() : firstOf(o.id, o.model, o.modelId, o.model_id, o.key, o.slug)
371
+ if (!MODEL_ID.test(raw)) return null
372
+ const free = group === "free" || o.free === true || o.free === "true"
373
+ const pass = group === "clinePass" || raw.startsWith("cline-pass/")
374
+ const given = firstOf(o.display_name, o.displayName, o.name, o.label)
375
+ const last = raw.split("/").pop()
376
+ return { id: raw, name: given && given !== raw && given !== last ? given : prettify(raw), free, pass }
377
+ }
378
+ const add = (e, group) => {
379
+ const m = entry(e, group)
380
+ if (!m) return
381
+ const was = by.get(m.id)
382
+ if (!was) {
383
+ by.set(m.id, m)
384
+ return
385
+ }
386
+ // a model in both recommended and free keeps the free mark (and name)
387
+ if (m.free && !was.free) {
388
+ was.free = true
389
+ was.name = m.name
390
+ }
391
+ if (m.pass) was.pass = true
392
+ }
393
+ for (const group of FEED_GROUPS) for (const e of Array.isArray(payload[group]) ? payload[group] : []) add(e, group)
394
+ return [...by.values()].map((m) => ({ ...m, name: m.name + (m.free ? " (free)" : m.pass ? " (ClinePass)" : "") }))
395
+ }
396
+
397
+ // fetchRecommended is Cline's recommended-models feed, which it serves without
398
+ // a sign-in
399
+ async function fetchRecommended() {
400
+ let res
401
+ try {
402
+ res = await fetch(`${API}/ai/cline/recommended-models`, {
403
+ headers: { Accept: "application/json", ...clientHeaders() },
404
+ signal: AbortSignal.timeout(10_000),
405
+ })
406
+ } catch (e) {
407
+ throw new Error(`Cline model list: ${e?.message ?? e}`)
408
+ }
409
+ if (!res.ok) throw new Error(`Cline model list: HTTP ${res.status}`)
410
+ const env = await res.json().catch(() => null)
411
+ return parseFeed(env && typeof env === "object" && "data" in env ? env.data : env)
412
+ }
413
+
414
+ // fetchCloudModels is Cline's whole cloud catalog, which its clients load
415
+ // beside the recommended feed (loadCloudModels reads /ai/cline/models): the
416
+ // usage-billed models the recommended feed doesn't name. Entries carry an id
417
+ // and a display name.
418
+ async function fetchCloudModels() {
419
+ let res
420
+ try {
421
+ res = await fetch(`${API}/ai/cline/models`, {
422
+ headers: { Accept: "application/json", ...clientHeaders() },
423
+ signal: AbortSignal.timeout(10_000),
424
+ })
425
+ } catch (e) {
426
+ throw new Error(`Cline model catalog: ${e?.message ?? e}`)
427
+ }
428
+ if (!res.ok) throw new Error(`Cline model catalog: HTTP ${res.status}`)
429
+ const j = await res.json().catch(() => null)
430
+ const list = Array.isArray(j) ? j : Array.isArray(j?.data) ? j.data : []
431
+ return list.flatMap((e) => {
432
+ // the catalog's ids are bare (no provider prefix), unlike the
433
+ // recommended feed's: any non-empty id is one
434
+ const id = typeof e?.id === "string" ? e.id.trim() : ""
435
+ if (!id) return []
436
+ const given = firstOf(e.display_name, e.displayName, e.name)
437
+ const last = id.split("/").pop()
438
+ return [{ id, name: given && given !== id && given !== last ? given : prettify(id), free: false, pass: id.startsWith("cline-pass/") }]
439
+ })
440
+ }
441
+
442
+ // fetchFeed is both of Cline's model lists merged, recommended first: the
443
+ // recommended feed is required, the cloud catalog is what it adds.
444
+ async function fetchFeed() {
445
+ const [rec, cloud] = await Promise.all([fetchRecommended(), fetchCloudModels().catch(() => [])])
446
+ const ms = [...rec]
447
+ const seen = new Set(ms.map((m) => m.id))
448
+ for (const m of cloud) {
449
+ if (seen.has(m.id)) continue
450
+ seen.add(m.id)
451
+ ms.push(m)
452
+ }
453
+ if (!ms.length) throw new Error("Cline listed no models")
454
+ return ms
455
+ }
456
+
457
+ const configModel = (m) => ({
458
+ name: m.name ?? m.id,
459
+ limit: { context: m.context ?? 0, output: 0 },
460
+ tool_call: true,
461
+ })
462
+
463
+ const runtimeModel = (m) => ({
464
+ id: m.id,
465
+ providerID: PROVIDER,
466
+ name: m.name || m.id,
467
+ api: { id: m.id, url: API, npm: "@ai-sdk/openai-compatible" },
468
+ status: "active",
469
+ headers: {},
470
+ options: {},
471
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
472
+ limit: { context: m.context ?? 0, output: 0 },
473
+ capabilities: {
474
+ temperature: true,
475
+ reasoning: false,
476
+ attachment: false,
477
+ toolcall: true,
478
+ input: { text: true, image: false, audio: false, video: false, pdf: false },
479
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
480
+ interleaved: false,
481
+ },
482
+ release_date: "",
483
+ variants: {},
484
+ free: !!m.free,
485
+ })
486
+
487
+ // ---- the reply as errors -----------------------------------------------------------
488
+
489
+ // the refusals Cline's own client keys on, as it spells them (errors.ts and
490
+ // ClineError.ts): whole phrases and codes, never a loose substring — a 403
491
+ // that happens to mention "tokens" is not a lapsed sign-in
492
+ const NOT_SUBSCRIBED = "the user is not subscribed to required model plan"
493
+ const NOT_SUBSCRIBED_FORMATTED = "no access to clinepass subscription models yet. subscribe to clinepass"
494
+
495
+ // failure is an upstream chat refusal as OpenAI's API gives it. A refused
496
+ // chat is the sign-in at work, not the sign-in gone: only a refused refresh
497
+ // marks the account (errorResponse keeps it). The gates Cline puts in front
498
+ // of a model each say what they want: a plan subscription, or money.
499
+ function failure(status, text) {
500
+ if (status < 400 || status > 599) status = 502
501
+ let msg = String(text ?? "").trim()
502
+ let j = null
503
+ try {
504
+ j = JSON.parse(text)
505
+ } catch {}
506
+ const code = firstOf(j?.error?.code, j?.code, j?.error?.error)
507
+ if (j) {
508
+ if (typeof j?.error?.message === "string") msg = j.error.message
509
+ else if (typeof j?.message === "string") msg = j.message
510
+ }
511
+ msg ||= STATUS_CODES[status] ?? `HTTP ${status}`
512
+ const low = msg.toLowerCase()
513
+ // a model the account isn't entitled to: the sign-in is fine, the plan
514
+ // isn't there (the cline-pass group, mostly)
515
+ if (code === "ENTITLEMENT_ERROR" || low.includes(NOT_SUBSCRIBED) || low.includes(NOT_SUBSCRIBED_FORMATTED))
516
+ return {
517
+ status: 403,
518
+ message: "this model needs a subscription the account hasn't got (ClinePass) — pick a usage-billed model, or subscribe at app.cline.bot (" + msg.replace(/^Error\s*\d*[::]?\s*/, "") + ")",
519
+ }
520
+ // an empty account: say what the gateway said and where to fill it. The
521
+ // code is the one Cline's client keys on; "Insufficient balance" is the
522
+ // gateway's older wording for the same answer
523
+ if (status === 402 || code === "insufficient_credits" || low.includes("insufficient_credits") || (low.includes("insufficient balance") && low.includes("cline credits balance"))) {
524
+ const bal = Number(j?.error?.current_balance ?? j?.current_balance)
525
+ const at = firstOf(j?.error?.buy_credits_url, j?.buy_credits_url) || "https://app.cline.bot/credits"
526
+ return { status: 429, message: `out of credits ($${Number.isFinite(bal) ? bal.toFixed(2) : "0.00"}) — top up at ${at}` }
527
+ }
528
+ // the account's token was refused: 401 always, and a 403 only when it
529
+ // says so in so many words — not when it merely mentions "token"
530
+ if (status === 401 || (status === 403 && /^(unauthorized|invalid[ _]token|sign[ -]?in|not signed in|token (?:expired|invalid|revoked)|(?:access |refresh )?token expired)$/i.test(msg.trim())))
531
+ return { status: 401, message: "the sign-in lapsed — sign in again" }
532
+ // a spend cap or a rate limit, as Cline's client names them
533
+ if (status === 429 || code === "SPEND_LIMIT_EXCEEDED" || /\b(?:rate limit|too many requests|quota exceeded|spend limit)\b/i.test(msg)) return { status: 429, message: "usage limit reached: " + msg }
534
+ return { status, message: msg.slice(0, 2000) }
535
+ }
536
+
537
+ const errorResponse = ({ status, message, signIn }) =>
538
+ new Response(JSON.stringify({ error: { message, type: "cline_error", code: status } }), {
539
+ status,
540
+ headers: { "Content-Type": "application/json", ...(signIn ? { "X-Magpie-Sign-In": signIn } : {}) },
541
+ })
542
+
543
+ // signed is res saying what it means for the sign-in: a renewed token cleared
544
+ // the lapse mark whatever came of the request
545
+ function signed(res, renewed) {
546
+ const said = renewed ? "renewed" : res.ok ? "kept" : null
547
+ if (!said) return res
548
+ const headers = new Headers(res.headers)
549
+ headers.delete("content-length")
550
+ headers.delete("content-encoding")
551
+ headers.set("X-Magpie-Sign-In", said)
552
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
553
+ }
554
+
555
+ async function bodyText(input, init) {
556
+ const b = init?.body ?? (input instanceof Request ? await input.clone().text() : undefined)
557
+ if (b === undefined || b === null) return ""
558
+ if (typeof b === "string") return b
559
+ if (b instanceof URLSearchParams) return b.toString()
560
+ if (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) return new TextDecoder().decode(b)
561
+ try {
562
+ return await new Response(b).text()
563
+ } catch {
564
+ return ""
565
+ }
566
+ }
567
+
568
+ // unwrapped is Cline's non-streaming answer with its envelope taken off: a
569
+ // finished chat completion rides as {"data": …}, the shape the official
570
+ // clients take apart. Streaming answers are plain SSE and pass untouched.
571
+ async function unwrapped(res) {
572
+ if (!(res.headers.get("content-type") ?? "").includes("application/json")) return res
573
+ const text = await res.text()
574
+ let j
575
+ try {
576
+ j = JSON.parse(text)
577
+ } catch {
578
+ return new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
579
+ }
580
+ if (j && typeof j === "object" && Array.isArray(j.data?.choices))
581
+ return new Response(JSON.stringify(j.data), { status: res.status, statusText: res.statusText, headers: { "Content-Type": "application/json" } })
582
+ return new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
583
+ }
584
+
585
+ // ---- usage -----------------------------------------------------------------------
586
+
587
+ // usd is a dollar amount as it's usually written: cents above a cent, four
588
+ // decimals under one (an account with half a cent isn't "$0.00" to its owner)
589
+ const usd = (n) => (n === 0 || n >= 0.01 ? `$${n.toFixed(2)}` : `$${Number(n.toFixed(4))}`)
590
+
591
+ // balanceWindow is the one balance shape Cline serves today: {balance} in
592
+ // millionths of a dollar — the chat gateway's current_balance is the same
593
+ // number with the decimal point moved six places
594
+ function balanceWindow(v) {
595
+ const n = typeof v?.balance === "number" ? v.balance : typeof v?.balance === "string" && Number.isFinite(Number(v.balance)) ? Number(v.balance) : NaN
596
+ if (!Number.isFinite(n) || n < 0) return null
597
+ return { name: "Credits", used: 0, display: `${usd(n / 1e6)} left` }
598
+ }
599
+
600
+ // balanceOf is the account's credit balance, a shape Cline hasn't fixed: a
601
+ // total with what is used or left of it, or just what is left
602
+ function balanceOf(v) {
603
+ if (!v || typeof v !== "object") return null
604
+ const flat = {}
605
+ const collect = (n, depth) => {
606
+ if (!n || typeof n !== "object" || depth > 2) return
607
+ for (const [k, x] of Object.entries(n)) {
608
+ if (typeof x === "number" && Number.isFinite(x)) flat[k.toLowerCase()] = x
609
+ else if (typeof x === "string" && x.trim() !== "" && Number.isFinite(Number(x.trim()))) flat[k.toLowerCase()] = Number(x.trim())
610
+ else collect(x, depth + 1)
611
+ }
612
+ }
613
+ collect(v, 0)
614
+ const pick = (keys) => {
615
+ for (const k of keys) if (typeof flat[k] === "number") return flat[k]
616
+ return NaN
617
+ }
618
+ const total = pick(["total", "totalcredits", "totalbalance", "creditstotal", "limit", "amount", "quota"])
619
+ const used = pick(["used", "usedcredits", "usedamount", "spent", "consumed", "usagetotal"])
620
+ const remaining = pick(["remaining", "remainingcredits", "remainingbalance", "left", "available", "balance", "credits", "current"])
621
+ const w = { name: "Credits", used: 0 }
622
+ if (Number.isFinite(total) && total > 0) {
623
+ if (Number.isFinite(used)) {
624
+ w.used = (100 * used) / total
625
+ w.display = `${compactNumber(used)} / ${compactNumber(total)}`
626
+ } else if (Number.isFinite(remaining) && remaining <= total) {
627
+ w.used = (100 * (total - remaining)) / total
628
+ w.display = `${compactNumber(total - remaining)} / ${compactNumber(total)}`
629
+ } else return null
630
+ } else if (Number.isFinite(remaining) && remaining > 0) {
631
+ w.display = `${compactNumber(remaining)} credits left`
632
+ } else if (Number.isFinite(used) && used > 0) {
633
+ w.used = used
634
+ w.display = `${compactNumber(used)} credits used`
635
+ } else return null
636
+ const at = timeOf(v, ["resetAt", "resetTime", "nextResetAt", "renewsAt", "expiresAt"])
637
+ if (at) w.resetsAt = at
638
+ return w
639
+ }
640
+
641
+ function usageOf(me, balance) {
642
+ const out = { windows: [] }
643
+ const plan = firstOf(me?.plan, me?.planType, me?.planName, me?.membership, me?.tier)
644
+ if (plan) out.plan = plan
645
+ const w = balanceWindow(balance) ?? balanceOf(balance)
646
+ if (w) out.windows.push(w)
647
+ return out
648
+ }
649
+
650
+ // ---- the plugin --------------------------------------------------------------------
651
+
652
+ export const ClinePlugin = async ({ client } = {}) => {
653
+ // the accounts fresh renewed this run: the sign-in mark comes off on the
654
+ // renewed token, whatever the request then met
655
+ const renewals = new WeakSet()
656
+ const renewed = (cred) => renewals.has(cred)
657
+ // the rotated pair, kept in memory beside what magpie saved: a save through
658
+ // client.auth.set may fail, and the next request must not spend a refresh
659
+ // token that was already spent (kiro's held). Every token this run has spent
660
+ // is kept, not only the last one — a store that failed once usually goes on
661
+ // failing, so getAuth keeps handing back the first of them — and each token
662
+ // is kept against the pair its own rotation chain reached, since one plugin
663
+ // instance serves every account of the provider at once: one newest pair
664
+ // shared by all of them would send an account on another account's token
665
+ const held = new Map() // a spent refresh token → its chain's newest pair
666
+
667
+ // remember saves a refreshed pair where magpie keeps it
668
+ const remember = async (next) => {
669
+ try {
670
+ await client?.auth?.set?.({
671
+ path: { id: PROVIDER },
672
+ body: {
673
+ type: "oauth",
674
+ access: next.access,
675
+ refresh: next.refresh,
676
+ expires: next.expires,
677
+ uid: next.uid ?? "",
678
+ email: next.email ?? "",
679
+ name: next.name ?? "",
680
+ accountId: next.accountId ?? "",
681
+ },
682
+ })
683
+ } catch {}
684
+ }
685
+
686
+ // refreshes are serialized: the refresh token is single-use, and two
687
+ // requests renewing at once spend one and lose the other
688
+ let lock = Promise.resolve()
689
+ const locked = (fn) => {
690
+ const run = lock.then(fn, fn)
691
+ lock = run.catch(() => {})
692
+ return run
693
+ }
694
+
695
+ // fresh is the account with a token still good: one near its end is
696
+ // renewed, and the rotated pair saved back. A transient refresh failure
697
+ // keeps the token that is still good, as Cline's own client does; only a
698
+ // token that has actually expired is the sign-in gone.
699
+ const fresh = (getAuth) =>
700
+ locked(async () => {
701
+ const a = authOf(await getAuth())
702
+ if (a.type === "api") {
703
+ if (!a.key) throw new Lapsed("Cline: no API key; add one to the account")
704
+ return { bearer: a.key, renewed: false, accountId: a.accountId }
705
+ }
706
+ if (!a.access && !a.refresh) throw new Lapsed("Cline: not signed in")
707
+ if (a.access && a.expires - Date.now() > REFRESH_LEAD) return { ...a, bearer: bearerOf(a), renewed: false }
708
+ // a pair this run already rotated is newer than the one getAuth
709
+ // still hands back: use it rather than re-spending a spent token
710
+ const h = held.get(a.refresh)
711
+ const r = h ? { ...a, access: h.access, refresh: h.refresh, expires: h.expires } : a
712
+ if (r.access && r.expires - Date.now() > REFRESH_LEAD) return { ...r, bearer: bearerOf(r), renewed: false }
713
+ if (!r.refresh) throw new Lapsed("Cline's access token has expired and there is no refresh token; sign in to Cline again")
714
+ let p
715
+ try {
716
+ p = await refresh(r)
717
+ } catch (e) {
718
+ // a token that is still good rides on through a refresh that
719
+ // failed for a while; an expired one is the sign-in gone
720
+ if (!e?.expired && r.access && r.expires - Date.now() > RETRYABLE_GRACE) return { ...r, bearer: bearerOf(r), renewed: false }
721
+ throw e
722
+ }
723
+ const next = { ...r, access: p.access, refresh: p.refresh, expires: p.expires, uid: p.uid || r.uid, email: p.email || r.email, name: p.name || r.name, accountId: r.accountId || p.accountId }
724
+ // the pair just rotated, held whatever the save through magpie does,
725
+ // against the token it spent and the one getAuth handed back: the
726
+ // next request offering either of them finds this chain's newest
727
+ const chain = held.get(a.refresh) ?? {}
728
+ Object.assign(chain, { access: next.access, refresh: next.refresh, expires: next.expires })
729
+ held.set(a.refresh, chain)
730
+ held.set(r.refresh, chain)
731
+ await remember(next)
732
+ const cred = { ...next, bearer: bearerOf(next), renewed: true }
733
+ renewals.add(cred)
734
+ return cred
735
+ })
736
+
737
+ const signedInError = (e) =>
738
+ errorResponse({
739
+ status: e?.expired ? 401 : 502,
740
+ message: String(e?.message ?? e).replace(/^Cline: /, ""),
741
+ signIn: e?.expired ? "expired" : undefined,
742
+ })
743
+
744
+ // usage is the account's credit balance, magpie's own hook. A read the
745
+ // account's token couldn't make is the sign-in, not the read.
746
+ const usage = async (getAuth) => {
747
+ try {
748
+ const cred = await fresh(getAuth)
749
+ const authz = { Authorization: `Bearer ${cred.bearer}` }
750
+ const me = await clineApi(`${API}/users/me`, { headers: authz })
751
+ const ids = [me?.clineUserId, me?.subject, me?.id, cred.uid].filter((v) => typeof v === "string" && v.trim())
752
+ let balance = null
753
+ let lastErr = null
754
+ for (const id of ids) {
755
+ try {
756
+ balance = await clineApi(`${API}/users/${encodeURIComponent(id)}/balance`, { headers: authz })
757
+ break
758
+ } catch (e) {
759
+ lastErr = e
760
+ }
761
+ }
762
+ if (!balance) throw lastErr ?? new Error("Cline usage: no user id")
763
+ return { ...usageOf(me, balance), user: firstOf(cred.accountId, cred.email, cred.uid, ids[0]), signIn: renewed(cred) ? "renewed" : "kept" }
764
+ } catch (e) {
765
+ return { windows: [], error: e?.message ?? String(e), signIn: e?.expired ? "expired" : "kept" }
766
+ }
767
+ }
768
+
769
+ return {
770
+ config: async (cfg) => {
771
+ cfg.provider ??= {}
772
+ const was = cfg.provider[PROVIDER] ?? {}
773
+ cfg.provider[PROVIDER] = {
774
+ name: "Cline",
775
+ npm: "@ai-sdk/openai-compatible",
776
+ api: API,
777
+ ...was,
778
+ models: { ...Object.fromEntries(DEFAULT_MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
779
+ }
780
+ },
781
+
782
+ auth: {
783
+ provider: PROVIDER,
784
+
785
+ async loader(getAuth) {
786
+ const a = await getAuth()
787
+ if (a?.type !== "oauth" && a?.type !== "api") return {}
788
+ return {
789
+ baseURL: API,
790
+ apiKey: "cline",
791
+ // every chat completion as Cline's gateway takes it: OpenAI's
792
+ // format, the account's token, Cline's own headers
793
+ async fetch(input, init = {}) {
794
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
795
+ if (!/\/chat\/completions$/.test(new URL(url).pathname))
796
+ return errorResponse({ status: 404, message: "only chat completions are served" })
797
+ let body
798
+ try {
799
+ body = await bodyText(input, init)
800
+ const chat = JSON.parse(body)
801
+ if (!chat || typeof chat !== "object" || Array.isArray(chat)) throw new Error("not a chat completion")
802
+ } catch {
803
+ return errorResponse({ status: 400, message: "a request that isn't a chat completion" })
804
+ }
805
+ let cred
806
+ try {
807
+ cred = await fresh(getAuth)
808
+ } catch (e) {
809
+ return signedInError(e)
810
+ }
811
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
812
+ headers.set("Authorization", `Bearer ${cred.bearer}`)
813
+ // the request says who it comes from, headers down, exactly as
814
+ // Cline's own client does — and rides its own task id
815
+ for (const [k, v] of Object.entries(clientHeaders(randomUUID()))) headers.set(k, v)
816
+ headers.delete("content-length")
817
+ headers.delete("host")
818
+ let res
819
+ try {
820
+ res = await fetch(url, { ...init, method: init?.method ?? "POST", headers, body, signal: init?.signal ?? (input instanceof Request ? input.signal : undefined) })
821
+ } catch (e) {
822
+ return signed(errorResponse({ status: 502, message: String(e?.message ?? e) }), renewed(cred))
823
+ }
824
+ // a renewed token cleared the lapse mark whatever the
825
+ // request then met, a refused one included (qoder's
826
+ // renewed()): the failure is reported, the mark stays off
827
+ if (!res.ok) return signed(errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20))), renewed(cred))
828
+ return signed(await unwrapped(res), renewed(cred))
829
+ },
830
+ }
831
+ },
832
+
833
+ usage,
834
+
835
+ methods: [
836
+ { type: "oauth", label: "Sign in to Cline (browser)", authorize: deviceSignIn },
837
+ { type: "api", label: "Cline API key (app.cline.bot → Settings → API Keys)" },
838
+ ],
839
+ },
840
+
841
+ provider: {
842
+ id: PROVIDER,
843
+ async models(provider) {
844
+ const have = provider?.models ?? {}
845
+ try {
846
+ const ms = await fetchFeed()
847
+ return Object.fromEntries(ms.map((m) => [m.id, runtimeModel(m)]))
848
+ } catch {
849
+ // a list Cline couldn't give is a failure, not the few
850
+ // configured: magpie keeps the list it had
851
+ return have
852
+ }
853
+ },
854
+ },
855
+ }
856
+ }
857
+
858
+ // for tests
859
+ export const _internal = {
860
+ firstOf,
861
+ compactNumber,
862
+ toMs,
863
+ timeOf,
864
+ authOf,
865
+ bearerOf,
866
+ parseAuth,
867
+ parseFeed,
868
+ prettify,
869
+ fetchFeed,
870
+ fetchRecommended,
871
+ fetchCloudModels,
872
+ balanceOf,
873
+ balanceWindow,
874
+ usd,
875
+ usageOf,
876
+ failure,
877
+ errorResponse,
878
+ bodyText,
879
+ unwrapped,
880
+ clientHeaders,
881
+ deviceAuthorize,
882
+ pollDevice,
883
+ refresh,
884
+ constants: { PROVIDER, API, WORKOS_CLIENT, WORKOS_PREFIX, CLIENT, DEFAULT_MODELS },
885
+ errors: { Lapsed },
886
+ }
887
+
888
+ // The shape the plugin host looks for: default {id, server}. The host takes
889
+ // this over the named exports, so also exporting ClinePlugin by name (as the
890
+ // tests import it) changes nothing the host does.
891
+ export default { id: PROVIDER, server: ClinePlugin }
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cline-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Cline (cline.bot) on your own account — usage billing, ClinePass and the free models — in OpenCode and magpie",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs", "README.md"],
9
+ "keywords": ["opencode", "opencode-plugin", "magpie", "cline", "clinepass"],
10
+ "license": "MIT"
11
+ }