Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
158
web/server/lib/skills-catalog/clawdhub/api.js
Normal file
158
web/server/lib/skills-catalog/clawdhub/api.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* ClawdHub API client
|
||||
*
|
||||
* ClawdHub is a public skill registry at https://clawdhub.com
|
||||
* This client provides methods to fetch skills list and download skill packages.
|
||||
*/
|
||||
|
||||
const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1';
|
||||
const CLAWDHUB_PAGE_LIMIT = 25;
|
||||
|
||||
// Rate limiting: ClawdHub allows 120 requests/minute
|
||||
const RATE_LIMIT_DELAY_MS = 100;
|
||||
let lastRequestTime = 0;
|
||||
|
||||
async function rateLimitedFetch(url, options = {}) {
|
||||
const maxAttempts = 10;
|
||||
|
||||
let lastResponse = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastRequestTime;
|
||||
if (elapsed < RATE_LIMIT_DELAY_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed));
|
||||
}
|
||||
lastRequestTime = Date.now();
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'OpenChamber/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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch paginated list of skills from ClawdHub
|
||||
* @param {Object} options
|
||||
* @param {string} [options.cursor] - Pagination cursor from previous response
|
||||
* @returns {Promise<{ items: Array, nextCursor?: string }>}
|
||||
*/
|
||||
export async function fetchClawdHubSkills({ cursor } = {}) {
|
||||
const url = cursor
|
||||
? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}`
|
||||
: `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`;
|
||||
|
||||
const response = await rateLimitedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const nextCursor =
|
||||
(typeof data.nextCursor === 'string' && data.nextCursor) ||
|
||||
(typeof data.next_cursor === 'string' && data.next_cursor) ||
|
||||
(typeof data.next === 'string' && data.next) ||
|
||||
(typeof data.cursor === 'string' && data.cursor) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
items: data.items || [],
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch details for a specific skill version
|
||||
* @param {string} slug - Skill slug/identifier
|
||||
* @param {string} [version='latest'] - Version string or 'latest'
|
||||
* @returns {Promise<{ skill: Object, version: Object }>}
|
||||
*/
|
||||
export async function fetchClawdHubSkillVersion(slug, version = 'latest') {
|
||||
// For 'latest', we need to first get the skill metadata to find the latest version
|
||||
if (version === 'latest') {
|
||||
const skillResponse = await rateLimitedFetch(`${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`);
|
||||
if (!skillResponse.ok) {
|
||||
throw new Error(`ClawdHub skill not found: ${slug}`);
|
||||
}
|
||||
const skillData = await skillResponse.json();
|
||||
const latestVersion = skillData.skill?.tags?.latest || skillData.latestVersion?.version;
|
||||
if (!latestVersion) {
|
||||
throw new Error(`No latest version found for skill: ${slug}`);
|
||||
}
|
||||
version = latestVersion;
|
||||
}
|
||||
|
||||
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`;
|
||||
const response = await rateLimitedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`ClawdHub version error (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a skill package as a ZIP buffer
|
||||
* @param {string} slug - Skill slug/identifier
|
||||
* @param {string} version - Specific version string
|
||||
* @returns {Promise<ArrayBuffer>} - ZIP file contents
|
||||
*/
|
||||
export async function downloadClawdHubSkill(slug, version) {
|
||||
const versionParam = typeof version === 'string' && version !== 'latest'
|
||||
? `&version=${encodeURIComponent(version)}`
|
||||
: '&tag=latest';
|
||||
const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}${versionParam}`;
|
||||
|
||||
const response = await rateLimitedFetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/zip',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill metadata without version details
|
||||
* @param {string} slug - Skill slug/identifier
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function fetchClawdHubSkillInfo(slug) {
|
||||
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`;
|
||||
const response = await rateLimitedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
Reference in New Issue
Block a user