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

This commit is contained in:
Jude.Chen committed 2026-10-07 03:47:11 +08:00
1 parent 900b3fd302
commit dec7f5ddaa
8 files changed
+212 -40

No files matched your search

+21 -2
View File
@@ -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
* <Svg>/<Path> 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.
*/
+2 -2
View File
@@ -316,8 +316,8 @@ export const zh: Record<string, string> = {
'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.':
+39 -1
View File
@@ -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 () => {
+2 -1
View File
@@ -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']);
+89 -18
View File
@@ -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 <API key>` 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<string, { id: string; label: string }> = {
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<string, unknown> | 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<T>(p: Promise<T>): Promise<T | undefined> {
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<string, unknown> | undefined): {
plan?: string;
renewsAt?: string;
} {
if (!plan) return {};
const p =
plan.plan && typeof plan.plan === 'object' ? (plan.plan as Record<string, unknown>) : {};
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' },
};
},
};
+2 -2
View File
@@ -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',
+33 -12
View File
@@ -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}
>
<View style={styles.content}>
{/* one line: logo, name, and the plan on the right, all centred on the same axis */}
<View style={styles.header}>
<ProviderIcon providerId={view.account.providerId} label={name} size={24} />
{/* empty lines are left out so a lone title centres on the icon */}
<View style={styles.titles}>
<ThemedText type="smallBold">{name}</ThemedText>
{subtitle ? (
<ThemedText type="small" themeColor="textSecondary">
{subtitle}
</ThemedText>
) : null}
<ThemedText type="smallBold" numberOfLines={1} style={styles.name}>
{name}
</ThemedText>
{subtitle ? (
<ThemedText
type="small"
themeColor="textSecondary"
numberOfLines={1}
style={styles.subtitle}
>
{subtitle}
</ThemedText>
) : null}
</View>
{/* status and plan end sit under the name, indented past the logo */}
{renewal || statusLabel(status, now, t) ? (
<View style={styles.meta}>
<StatusBadge status={status} now={now} />
{renewal ? (
<ThemedText
type="small"
@@ -68,8 +79,7 @@ export function AccountCard({
</ThemedText>
) : null}
</View>
<StatusBadge status={status} now={now} />
</View>
) : null}
{meters.length > 0 ? (
<View style={styles.meters}>
@@ -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 },
});
+24 -2
View File
@@ -2,8 +2,9 @@
* Official provider marks, stored as structured path data so the component can render them with
* <Svg>/<Path> 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<string, ProviderMark> = {
},
],
},
'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<string, ProviderMark> = {
},
],
},
relay: {
viewBox: '0 0 24 24',
paths: [{ d: 'M4 7h12V4l5 4-5 4V9H4zM20 15H8v-3l-5 4 5 4v-3h12z' }],
},
};