From dec7f5ddaa4f8ac52d3ca4723848fbbd04a71109 Mon Sep 17 00:00:00 2001 From: "Jude.Chen" Date: Wed, 7 Oct 2026 03:47:11 +0800 Subject: [PATCH] ClinePass limits, plan and period end in the Cline provider; icons for Cline, Command Code and API relays; card header: plan on the right, status under the name --- scripts/fetch-provider-icons.mjs | 23 +++++- src/i18n/zh.ts | 4 +- src/providers/__tests__/cline.test.ts | 40 ++++++++- src/providers/__tests__/search.test.ts | 3 +- src/providers/cline/index.ts | 107 ++++++++++++++++++++----- src/providers/registry.ts | 4 +- src/ui/account-card.tsx | 45 ++++++++--- src/ui/provider-icons.ts | 26 +++++- 8 files changed, 212 insertions(+), 40 deletions(-) diff --git a/scripts/fetch-provider-icons.mjs b/scripts/fetch-provider-icons.mjs index 42b2d7b..34ab17f 100644 --- a/scripts/fetch-provider-icons.mjs +++ b/scripts/fetch-provider-icons.mjs @@ -37,12 +37,25 @@ const SOURCES = { runway: 'lobehub:runway', poe: 'lobehub:poe', deepseek: 'lobehub:deepseek', + 'command-goat': 'lobehub:commandcode', + cline: 'lobehub:cline', 'opencode-go': 'lobehub:opencode', opencode: 'lobehub:opencode', claude: 'lobehub:claude', cursor: 'lobehub:cursor', }; +/** + * Marks drawn for this app, for entries that are not a brand. `relay` stands for any API relay + * (New API, Sub2API, One API...): two opposing arrows, i.e. requests forwarded both ways. + */ +const LOCAL = { + relay: { + viewBox: '0 0 24 24', + paths: [{ d: 'M4 7h12V4l5 4-5 4V9H4zM20 15H8v-3l-5 4 5 4v-3h12z' }], + }, +}; + const urlFor = (ref) => { const [kind, slug] = ref.split(':'); return kind === 'lobehub' @@ -89,6 +102,11 @@ for (const [id, ref] of Object.entries(SOURCES)) { console.log(`ok ${id.padEnd(14)} viewBox=${viewBox} paths=${paths.length}`); } +for (const [id, mark] of Object.entries(LOCAL)) { + entries.push({ id, ...mark }); + console.log(`ok ${id.padEnd(14)} (drawn locally)`); +} + const data = entries .map( ({ id, viewBox, paths }) => @@ -104,8 +122,9 @@ const file = `/** * Official provider marks, stored as structured path data so the component can render them with * / and tint them with the theme text colour (see ui/provider-icon.tsx). * - * Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub). - * Brands remain the property of their owners; used here only to identify the provider. + * Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub); the API relay mark + * is drawn for this app. Brands remain the property of their owners; used here only to identify + * the provider. * * Regenerate with scripts/fetch-provider-icons.mjs when a brand updates its mark. */ diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 409a7d0..7d9f5a3 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -316,8 +316,8 @@ export const zh: Record = { 'Cookie sign-in removed; existing accounts are read-only.': '已移除 Cookie 登录,已有账号仅可查看。', 'No usage connection yet.': '暂不支持自动采集。', - 'Shows your Cline credits. Create an API key at app.cline.bot under Settings > API Keys.': - '显示 Cline 额度余额。请在 app.cline.bot 的 Settings > API Keys 中创建 API Key。', + 'Shows your Cline credits and, with ClinePass, the 5-hour, weekly and monthly limits. Create an API key at app.cline.bot under Settings > API Keys.': + '显示 Cline 额度余额;订阅了 ClinePass 时还会显示 5 小时、每周和每月用量。请在 app.cline.bot 的 Settings > API Keys 中创建 API Key。', // subscriptions, forecasts, reset notices, widget '{name}: plan ends or renews {date}': '{name}:套餐将于 {date} 到期或续费', 'Within a day. Check your subscription if you do not plan to keep it.': diff --git a/src/providers/__tests__/cline.test.ts b/src/providers/__tests__/cline.test.ts index 7e124f6..0767b80 100644 --- a/src/providers/__tests__/cline.test.ts +++ b/src/providers/__tests__/cline.test.ts @@ -40,7 +40,45 @@ describe('Cline credits', () => { const s = await clinePlugin().fetchUsage('a', key, ctxWith(f.fetch)); expect(s.plan).toBe('Team'); expect(s.meters[0].kind).toEqual({ type: 'balance', value: 12, unit: 'USD' }); - expect(f.calls.map((c) => c.url)).toEqual([ME, orgBalance('o1')]); + expect(f.calls.map((c) => c.url)).toContain(orgBalance('o1')); + expect(f.calls.map((c) => c.url)).not.toContain(userBalance('u1')); + }); + + it('adds the ClinePass 5-hour, weekly and monthly windows, the plan and its period end', async () => { + const f = fakeFetch({ + [ME]: { json: { success: true, data: { id: 'u1', organizations: [] } } }, + [userBalance('u1')]: { json: { success: true, data: { balance: 0 } } }, + [`${CLINE_API}/api/v1/users/me/plan/usage-limits`]: { + json: { + success: true, + data: { + limits: [ + { type: 'monthly', percentUsed: 12.5, resetsAt: '2026-11-01T00:00:00Z' }, + { type: 'five_hour', percentUsed: 140, resetsAt: '2026-10-06T15:00:00Z' }, + { type: 'weekly', percentUsed: 40, resetsAt: null }, + { type: 'mystery', percentUsed: 1 }, + ], + }, + }, + }, + [`${CLINE_API}/api/v1/users/me/plan`]: { + json: { + success: true, + data: { plan: { displayName: 'ClinePass' }, currentPeriodEnd: '2026-11-01T00:00:00Z' }, + }, + }, + }); + const s = await clinePlugin().fetchUsage('a', key, ctxWith(f.fetch)); + expect(s.plan).toBe('ClinePass'); + expect(s.renewsAt).toBe('2026-11-01T00:00:00.000Z'); + expect(s.meters.map((m) => [m.id, m.kind])).toEqual([ + ['session', { type: 'percent', used: 100 }], + ['weekly', { type: 'percent', used: 40 }], + ['monthly', { type: 'percent', used: 12.5 }], + ['credits', { type: 'balance', value: 0, unit: 'USD' }], + ]); + expect(s.meters[0].resetsAt).toBe('2026-10-06T15:00:00.000Z'); + expect(s.meters[1].resetsAt).toBeUndefined(); }); it('reports a rejected key as an expired login and an unknown shape as unsupported', async () => { diff --git a/src/providers/__tests__/search.test.ts b/src/providers/__tests__/search.test.ts index aa6e2ee..8aef7f6 100644 --- a/src/providers/__tests__/search.test.ts +++ b/src/providers/__tests__/search.test.ts @@ -10,7 +10,8 @@ describe('provider search', () => { }); it('matches names and ids, ignoring case, spaces and punctuation', () => { - expect(names('cline')).toEqual(['Cline']); + expect(names('cline')).toEqual(['Cline / ClinePass']); + expect(names('clinepass')).toEqual(['Cline / ClinePass']); expect(names('OPEN router')).toEqual(['OpenRouter']); expect(names('chatgpt/codex')).toEqual(['ChatGPT / Codex']); expect(names('command-goat')).toEqual(['Command Code']); diff --git a/src/providers/cline/index.ts b/src/providers/cline/index.ts index 716f3cc..785d522 100644 --- a/src/providers/cline/index.ts +++ b/src/providers/cline/index.ts @@ -3,20 +3,32 @@ import { requestJson } from '@/core/http'; import type { Credential, FetchContext, Meter, ProviderPlugin, UsageSnapshot } from '@/core/types'; /** - * Cline account credits (prepaid, pay-as-you-go). + * Cline: pay-as-you-go credits and the ClinePass subscription, both on the same account and key. * - * Verified against the official docs (docs.cline.bot, Enterprise API reference) and the open-source - * Cline SDK/CLI (sdk/packages/core/src/account/cline-account-service.ts, apps/cli/src/utils/output.ts): + * Verified against the official docs (docs.cline.bot: Enterprise API reference, ClinePass) and the + * open-source Cline SDK and extension (sdk/packages/core/src/account/cline-account-service.ts, + * apps/vscode/webview-ui/src/utils/format.ts): * - auth: `Authorization: Bearer ` from app.cline.bot > Settings > API Keys * - `GET /api/v1/users/me` -> `{ id, organizations: [{ organizationId, active, ... }] }` - * - `GET /api/v1/users/{id}/balance` and `GET /api/v1/organizations/{orgId}/balance` -> `{ balance }` - * - responses may be wrapped as `{ success, data }`; balances are millionths of a dollar + * - `GET /api/v1/users/{id}/balance` or `/organizations/{orgId}/balance` -> `{ balance }` + * in microcredits: the extension shows `balance / 100 / 10000`, i.e. millionths of a dollar + * - `GET /api/v1/users/me/plan` -> `{ plan: { displayName, name }, currentPeriodEnd, cancelAt }` + * - `GET /api/v1/users/me/plan/usage-limits` -> `{ limits: [{ type: five_hour | weekly | monthly, + * percentUsed, resetsAt }] }` (ClinePass only; other accounts get an error) + * - responses may be wrapped as `{ success, data }` * When an organization is active, its balance is the one Cline itself shows and spends from. */ export const CLINE_API = 'https://api.cline.bot'; const MICRO_DOLLARS = 1_000_000; +/** ClinePass windows, mapped onto the meter ids the rest of the app knows (forecasts, widget). */ +const PASS_WINDOWS: Record = { + five_hour: { id: 'session', label: '5-hour window' }, + weekly: { id: 'weekly', label: 'Weekly' }, + monthly: { id: 'monthly', label: 'Monthly' }, +}; + /** Unwraps `{ success, data }` envelopes; plain payloads pass through. */ export function unwrapCline(json: unknown): Record | undefined { if (!json || typeof json !== 'object') return undefined; @@ -36,6 +48,56 @@ async function getJson(url: string, token: string, ctx: FetchContext) { return unwrapCline(res.json); } +/** Optional parts (ClinePass, plan) must not fail the account; a rejected key still does. */ +async function optional(p: Promise): Promise { + try { + return await p; + } catch (e) { + if (e instanceof AuthExpiredError) throw e; + return undefined; + } +} + +export function passMeters(limits: unknown): Meter[] { + if (!limits || typeof limits !== 'object') return []; + const list = (limits as { limits?: unknown }).limits; + if (!Array.isArray(list)) return []; + const out: Meter[] = []; + for (const raw of list) { + if (!raw || typeof raw !== 'object') continue; + const l = raw as { type?: unknown; percentUsed?: unknown; resetsAt?: unknown }; + const w = typeof l.type === 'string' ? PASS_WINDOWS[l.type] : undefined; + if (!w || typeof l.percentUsed !== 'number' || !Number.isFinite(l.percentUsed)) continue; + const reset = typeof l.resetsAt === 'string' ? Date.parse(l.resetsAt) : NaN; + out.push({ + id: w.id, + label: w.label, + kind: { type: 'percent', used: Math.min(100, Math.max(0, l.percentUsed)) }, + scope: { type: 'overall' }, + resetsAt: Number.isFinite(reset) ? new Date(reset).toISOString() : undefined, + }); + } + // always 5-hour, weekly, monthly + const order = ['session', 'weekly', 'monthly']; + return out.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); +} + +/** Plan name and the date it renews (or ends, when cancelled). */ +export function planInfo(plan: Record | undefined): { + plan?: string; + renewsAt?: string; +} { + if (!plan) return {}; + const p = + plan.plan && typeof plan.plan === 'object' ? (plan.plan as Record) : {}; + const name = [p.displayName, p.name].find((v) => typeof v === 'string' && v.trim()) as + string | undefined; + const end = [plan.cancelAt, plan.currentPeriodEnd].find( + (v) => typeof v === 'string' && Number.isFinite(Date.parse(v)), + ) as string | undefined; + return { plan: name, renewsAt: end ? new Date(Date.parse(end)).toISOString() : undefined }; +} + export function clinePlugin(): ProviderPlugin { return { id: 'cline', @@ -62,24 +124,33 @@ export function clinePlugin(): ProviderPlugin { (o as { active?: unknown }).active === true && typeof (o as { organizationId?: unknown }).organizationId === 'string', ); - const url = active + const balanceUrl = active ? `${CLINE_API}/api/v1/organizations/${encodeURIComponent(active.organizationId)}/balance` : `${CLINE_API}/api/v1/users/${encodeURIComponent(userId)}/balance`; - const balance = (await getJson(url, cred.key, ctx))?.balance; - if (typeof balance !== 'number' || !Number.isFinite(balance)) { - return { ...base, meters: [], status: { type: 'unsupported' } }; + + const [balanceRes, limits, plan] = await Promise.all([ + optional(getJson(balanceUrl, cred.key, ctx)), + optional(getJson(`${CLINE_API}/api/v1/users/me/plan/usage-limits`, cred.key, ctx)), + optional(getJson(`${CLINE_API}/api/v1/users/me/plan`, cred.key, ctx)), + ]); + + const meters = passMeters(limits); + const balance = balanceRes?.balance; + if (typeof balance === 'number' && Number.isFinite(balance)) { + meters.push({ + id: 'credits', + label: 'Credits balance', + kind: { type: 'balance', value: balance / MICRO_DOLLARS, unit: 'USD' }, + scope: { type: 'overall' }, + }); } - const meter: Meter = { - id: 'credits', - label: 'Credits balance', - kind: { type: 'balance', value: balance / MICRO_DOLLARS, unit: 'USD' }, - scope: { type: 'overall' }, - }; + const info = planInfo(plan); return { ...base, - plan: active?.name ? String(active.name) : undefined, - meters: [meter], - status: { type: 'ok' }, + plan: info.plan ?? (active?.name ? String(active.name) : undefined), + renewsAt: info.renewsAt, + meters, + status: meters.length > 0 ? { type: 'ok' } : { type: 'unsupported' }, }; }, }; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bdd7d84..5451586 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -119,11 +119,11 @@ export const providers: ProviderMeta[] = [ }, { id: 'cline', - name: 'Cline', + name: 'Cline / ClinePass', automation: 'plugin', auth: ['apiKey'], plugin: clinePlugin(), - note: 'Shows your Cline credits. Create an API key at app.cline.bot under Settings > API Keys.', + note: 'Shows your Cline credits and, with ClinePass, the 5-hour, weekly and monthly limits. Create an API key at app.cline.bot under Settings > API Keys.', }, { id: 'opencode-go', diff --git a/src/ui/account-card.tsx b/src/ui/account-card.tsx index aa1c32c..6d4a3fc 100644 --- a/src/ui/account-card.tsx +++ b/src/ui/account-card.tsx @@ -10,7 +10,7 @@ import { renewalInfo } from '@/ui/meter-text'; import { MeterRow } from '@/ui/meter-row'; import { PressableScale } from '@/ui/motion'; import { ProviderIcon } from '@/ui/provider-icon'; -import { StatusBadge } from '@/ui/status-badge'; +import { StatusBadge, statusLabel } from '@/ui/status-badge'; export { forecastText, meterTitle, renewalInfo, resetText } from '@/ui/meter-text'; @@ -48,16 +48,27 @@ export function AccountCard({ scaleTo={0.985} > + {/* one line: logo, name, and the plan on the right, all centred on the same axis */} - {/* empty lines are left out so a lone title centres on the icon */} - - {name} - {subtitle ? ( - - {subtitle} - - ) : null} + + {name} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {/* status and plan end sit under the name, indented past the logo */} + {renewal || statusLabel(status, now, t) ? ( + + {renewal ? ( ) : null} - - + ) : null} {meters.length > 0 ? ( @@ -105,6 +115,17 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: Spacing.two, }, - titles: { gap: Spacing.half, flexShrink: 1, flexGrow: 1, justifyContent: 'center' }, + name: { flexShrink: 1, flexGrow: 1 }, + // the plan yields space to the name first, but never collapses to nothing + subtitle: { flexShrink: 2, textAlign: 'right', minWidth: 48 }, + // 24pt logo + 8pt gap: aligned with the name above + meta: { + marginTop: -Spacing.two, + paddingLeft: 24 + Spacing.two, + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: Spacing.two, + }, meters: { gap: Spacing.two }, }); diff --git a/src/ui/provider-icons.ts b/src/ui/provider-icons.ts index 4b7bb67..47d2567 100644 --- a/src/ui/provider-icons.ts +++ b/src/ui/provider-icons.ts @@ -2,8 +2,9 @@ * Official provider marks, stored as structured path data so the component can render them with * / and tint them with the theme text colour (see ui/provider-icon.tsx). * - * Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub). - * Brands remain the property of their owners; used here only to identify the provider. + * Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub); the API relay mark + * is drawn for this app. Brands remain the property of their owners; used here only to identify + * the provider. * * Regenerate with scripts/fetch-provider-icons.mjs when a brand updates its mark. */ @@ -108,6 +109,23 @@ export const providerIcons: Record = { }, ], }, + 'command-goat': { + viewBox: '0 0 24 24', + paths: [ + { + d: 'M19.334 0a4.67 4.67 0 0 0-4.666 4.668v2H9.334v-2A4.67 4.67 0 0 0 4.668 0 4.67 4.67 0 0 0 0 4.668a4.67 4.67 0 0 0 4.668 4.665h2v5.334h-2A4.67 4.67 0 0 0 0 19.334 4.67 4.67 0 0 0 4.668 24a4.67 4.67 0 0 0 4.666-4.666v-2h5.334v2A4.67 4.67 0 0 0 19.334 24 4.67 4.67 0 0 0 24 19.334a4.67 4.67 0 0 0-4.666-4.667h-2V9.333h2A4.67 4.67 0 0 0 24 4.668 4.673 4.673 0 0 0 19.334 0m-2 6.666v-2c0-1.107.893-2 2-2s2 .893 2 2-.893 2-2 2zm-12.668 0c-1.107 0-2-.893-2-2s.893-2 2-2 2 .893 2 2v2zm4.666 8V9.334h5.334v5.334zm10.002 6.667c-1.107 0-2-.893-2-2v-2h2c1.107 0 2 .892 2 2 0 1.107-.893 2-2 2m-14.668 0c-1.107 0-2-.893-2-2 0-1.108.893-2 2-2h2v2c0 1.107-.893 2-2 2', + }, + ], + }, + cline: { + viewBox: '0 0 24 24', + paths: [ + { + d: 'M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z', + }, + { d: 'M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z' }, + ], + }, 'opencode-go': { viewBox: '0 0 24 24', paths: [{ d: 'M16 6H8v12h8V6zm4 16H4V2h16v20z' }], @@ -132,4 +150,8 @@ export const providerIcons: Record = { }, ], }, + relay: { + viewBox: '0 0 24 24', + paths: [{ d: 'M4 7h12V4l5 4-5 4V9H4zM20 15H8v-3l-5 4 5 4v-3h12z' }], + }, };