Merge branch 'main' into fix/connection-status-resend
Signed-off-by: VinciYan <36335240+VinciYan@users.noreply.github.com>
This commit is contained in:
@@ -173,17 +173,20 @@ const readSharedSettingsFromDisk = (): Record<string, unknown> => {
|
||||
};
|
||||
|
||||
const writeSharedSettingsToDisk = async (changes: Record<string, unknown>): Promise<void> => {
|
||||
let tmp: string | null = null;
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
|
||||
const current = readSharedSettingsFromDisk();
|
||||
const next: Record<string, unknown> = { ...current, ...changes };
|
||||
// Atomic write: tmp file + rename. Readers never see a partial/truncated
|
||||
// JSON that would fail to parse and silently get coerced to {}.
|
||||
const tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fs.promises.writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
|
||||
await fs.promises.rename(tmp, OPENCHAMBER_SHARED_SETTINGS_PATH);
|
||||
} catch {
|
||||
// ignore
|
||||
if (tmp) {
|
||||
await fs.promises.rm(tmp, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -327,6 +327,33 @@ describe('Z.ai quota provider (VS Code parity)', () => {
|
||||
assert.equal(windows['MCP Tools']!.windowSeconds, 30 * 24 * 60 * 60);
|
||||
assert.equal(windows['MCP Tools']!.resetAt, 1787128459979);
|
||||
});
|
||||
|
||||
test('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
code: 200,
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 },
|
||||
{ type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 },
|
||||
],
|
||||
level: 'pro',
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('zai-coding-plan');
|
||||
const windows = result.usage!.windows;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.planLabel, 'pro');
|
||||
assert.equal(windows['5h']!.usedPercent, 1);
|
||||
assert.equal(windows['5h']!.windowSeconds, 5 * 60 * 60);
|
||||
assert.equal(windows['5h']!.resetAt, 1787257978907);
|
||||
assert.equal(windows['5h']!.valueLabel, '65 / 12k credits');
|
||||
assert.equal(windows.weekly!.usedPercent, 1);
|
||||
assert.equal(windows.weekly!.windowSeconds, 7 * 24 * 60 * 60);
|
||||
assert.equal(windows.weekly!.resetAt, 1787844668997);
|
||||
assert.equal(windows.weekly!.valueLabel, '65 / 60k credits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NeuralWatt quota provider (VS Code parity)', () => {
|
||||
|
||||
@@ -72,13 +72,31 @@ type ZaiLimit = {
|
||||
type?: string;
|
||||
number?: number;
|
||||
unit?: number;
|
||||
usage?: number;
|
||||
currentValue?: number;
|
||||
remaining?: number;
|
||||
nextResetTime?: number;
|
||||
percentage?: number;
|
||||
};
|
||||
|
||||
// CREDIT_LIMIT entries carry `usage` (total credits) and `currentValue` (consumed);
|
||||
// TOKENS_LIMIT entries only carry a percentage.
|
||||
const formatZaiCreditAmount = (value: number): string => {
|
||||
if (value < 1000) return value.toLocaleString('en-US');
|
||||
return `${Math.round(value / 100) / 10}k`;
|
||||
};
|
||||
|
||||
const formatZaiCreditValueLabel = (limit: ZaiLimit): string | null => {
|
||||
const used = toNumber(limit.currentValue);
|
||||
const total = toNumber(limit.usage);
|
||||
if (used === null || total === null) return null;
|
||||
return `${formatZaiCreditAmount(used)} / ${formatZaiCreditAmount(total)} credits`;
|
||||
};
|
||||
|
||||
type ZaiPayload = {
|
||||
data?: {
|
||||
limits?: ZaiLimit[];
|
||||
level?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -411,15 +429,20 @@ const buildResult = (data: {
|
||||
configured: boolean;
|
||||
usage?: ProviderUsage | null;
|
||||
error?: string;
|
||||
}): ProviderResult => ({
|
||||
providerId: data.providerId,
|
||||
providerName: data.providerName,
|
||||
ok: data.ok,
|
||||
configured: data.configured,
|
||||
usage: data.usage ?? null,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
planLabel?: string | null;
|
||||
}): ProviderResult => {
|
||||
const result: ProviderResult = {
|
||||
providerId: data.providerId,
|
||||
providerName: data.providerName,
|
||||
ok: data.ok,
|
||||
configured: data.configured,
|
||||
usage: data.usage ?? null,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
if (data.planLabel) result.planLabel = data.planLabel;
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveXaiAuth = (): XaiAuthEntry | null => {
|
||||
const entry = getProviderAuth('xai');
|
||||
@@ -1291,7 +1314,7 @@ const buildClaudeRateLimitResult = (): ProviderResult => (
|
||||
providerName: 'Claude',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Rate limited by Anthropic. Retrying shortly.',
|
||||
error: 'Rate limited. Retrying soon.',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2059,16 +2082,19 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
||||
const payload = await response.json() as ZaiPayload;
|
||||
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
|
||||
const windows: Record<string, UsageWindow> = {};
|
||||
for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(tokensLimit as Record<string, unknown>);
|
||||
// The API renamed TOKENS_LIMIT to CREDIT_LIMIT; field semantics stayed the same,
|
||||
// so both limit types map to the same windows.
|
||||
for (const limit of limits.filter((entry) => entry?.type === 'TOKENS_LIMIT' || entry?.type === 'CREDIT_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(limit as Record<string, unknown>);
|
||||
const windowLabel = resolveWindowLabel(windowSeconds);
|
||||
const resetAt = tokensLimit.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
|
||||
const usedPercent = typeof tokensLimit.percentage === 'number' ? tokensLimit.percentage : null;
|
||||
const resetAt = limit.nextResetTime ? normalizeTimestamp(limit.nextResetTime) : null;
|
||||
const usedPercent = typeof limit.percentage === 'number' ? limit.percentage : null;
|
||||
|
||||
windows[windowLabel] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt,
|
||||
valueLabel: formatZaiCreditValueLabel(limit),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2087,6 +2113,7 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows },
|
||||
planLabel: payload?.data?.level || null,
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
|
||||
@@ -33,15 +33,6 @@ type SkillFrontmatter = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ClawdHubSkillMetadata = {
|
||||
slug: string;
|
||||
version: string;
|
||||
displayName?: string;
|
||||
owner?: string;
|
||||
downloads?: number;
|
||||
stars?: number;
|
||||
};
|
||||
|
||||
type SkillsCatalogItem = {
|
||||
repoSource: string;
|
||||
repoSubpath?: string;
|
||||
@@ -51,9 +42,7 @@ type SkillsCatalogItem = {
|
||||
description?: string;
|
||||
installable: boolean;
|
||||
warnings?: string[];
|
||||
clawdhub?: ClawdHubSkillMetadata;
|
||||
};
|
||||
|
||||
type SkillsCatalogItemWithBadge = SkillsCatalogItem & {
|
||||
sourceId: string;
|
||||
installed: { isInstalled: boolean; scope?: SkillScope; source?: SkillInstallSource };
|
||||
@@ -84,143 +73,27 @@ const CURATED_SOURCES: CuratedSource[] = [
|
||||
defaultSubpath: 'skills',
|
||||
},
|
||||
{
|
||||
id: 'clawdhub',
|
||||
label: 'ClawHub',
|
||||
description: 'Community skill registry with vector search',
|
||||
source: 'clawdhub:registry',
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
description: "OpenAI's curated skills",
|
||||
source: 'openai/skills',
|
||||
defaultSubpath: 'skills/.curated',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
description: "Cursor's plugin skills",
|
||||
source: 'cursor/plugins',
|
||||
defaultSubpath: 'pstack/skills',
|
||||
},
|
||||
{
|
||||
id: 'mattpocock',
|
||||
label: 'Matt Pocock',
|
||||
description: 'Matt Pocock skills collection',
|
||||
source: 'mattpocock/skills',
|
||||
},
|
||||
];
|
||||
|
||||
// ============== ClawdHub API ==============
|
||||
|
||||
const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1';
|
||||
const CLAWDHUB_PAGE_LIMIT = 25;
|
||||
const CLAWDHUB_RATE_LIMIT_MS = 100;
|
||||
let clawdhubLastRequest = 0;
|
||||
|
||||
function isClawdHubSource(source: string): boolean {
|
||||
return typeof source === 'string' && source.startsWith('clawdhub:');
|
||||
}
|
||||
|
||||
async function clawdhubFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
const maxAttempts = 10;
|
||||
let lastResponse: Response | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - clawdhubLastRequest;
|
||||
if (elapsed < CLAWDHUB_RATE_LIMIT_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, CLAWDHUB_RATE_LIMIT_MS - elapsed));
|
||||
}
|
||||
clawdhubLastRequest = Date.now();
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'OpenChamber-VSCode/1.0',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
lastResponse = response;
|
||||
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (attempt < maxAttempts - 1) {
|
||||
const waitMs = 50 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return lastResponse as Response;
|
||||
}
|
||||
|
||||
type ClawdHubSkillListItem = {
|
||||
slug: string;
|
||||
displayName?: string;
|
||||
summary?: string;
|
||||
tags?: { latest?: string };
|
||||
latestVersion?: { version?: string };
|
||||
stats?: { downloads?: number; stars?: number };
|
||||
owner?: { handle?: string };
|
||||
};
|
||||
|
||||
type ClawdHubSkillsResponse = {
|
||||
items: ClawdHubSkillListItem[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
async function scanClawdHub(): Promise<SkillsRepoScanResult> {
|
||||
try {
|
||||
const allItems: SkillsCatalogItem[] = [];
|
||||
let cursor: string | null = null;
|
||||
const maxPages = 20;
|
||||
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const url = cursor
|
||||
? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}`
|
||||
: `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`;
|
||||
|
||||
let data: ClawdHubSkillsResponse;
|
||||
|
||||
try {
|
||||
const response = await clawdhubFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClawdHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
data = (await response.json()) as ClawdHubSkillsResponse;
|
||||
} catch (error) {
|
||||
if (page > 0 && allItems.length > 0) {
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const item of data.items || []) {
|
||||
const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0';
|
||||
|
||||
allItems.push({
|
||||
repoSource: 'clawdhub:registry',
|
||||
skillDir: item.slug,
|
||||
skillName: item.slug,
|
||||
frontmatterName: item.displayName || item.slug,
|
||||
description: item.summary || undefined,
|
||||
installable: true,
|
||||
clawdhub: {
|
||||
slug: item.slug,
|
||||
version: latestVersion,
|
||||
displayName: item.displayName,
|
||||
owner: item.owner?.handle,
|
||||
downloads: item.stats?.downloads || 0,
|
||||
stars: item.stats?.stars || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.nextCursor) break;
|
||||
cursor = data.nextCursor;
|
||||
}
|
||||
|
||||
// Sort by downloads (most popular first)
|
||||
allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
|
||||
|
||||
return { ok: true, items: allItems };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'networkError',
|
||||
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateSkillName(skillName: string): boolean {
|
||||
if (skillName.length < 1 || skillName.length > 64) return false;
|
||||
return SKILL_NAME_PATTERN.test(skillName);
|
||||
@@ -716,40 +589,6 @@ export async function getSkillsCatalog(
|
||||
const itemsBySource: Record<string, SkillsCatalogItemWithBadge[]> = {};
|
||||
|
||||
for (const src of sources) {
|
||||
// Handle ClawdHub sources separately (API-based, not git-based)
|
||||
if (isClawdHubSource(src.source)) {
|
||||
const cacheKey = 'clawdhub:registry';
|
||||
let cached = !refresh ? catalogCache.get(cacheKey) : null;
|
||||
if (cached && Date.now() >= cached.expiresAt) {
|
||||
catalogCache.delete(cacheKey);
|
||||
cached = null;
|
||||
}
|
||||
|
||||
let items: SkillsCatalogItem[] = [];
|
||||
if (cached) {
|
||||
items = cached.items;
|
||||
} else {
|
||||
const scanned = await scanClawdHub();
|
||||
if (!scanned.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
items = scanned.items || [];
|
||||
catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items });
|
||||
}
|
||||
|
||||
itemsBySource[src.id] = items.map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false },
|
||||
};
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle GitHub sources (git clone based)
|
||||
const parsed = parseSkillRepoSource(src.source);
|
||||
if (!parsed.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
|
||||
Reference in New Issue
Block a user