feat: Integrate ClawdHub skill registry
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// Rate limiting: ClawdHub allows 120 requests/minute
|
||||
const RATE_LIMIT_DELAY_MS = 100;
|
||||
let lastRequestTime = 0;
|
||||
|
||||
async function rateLimitedFetch(url, options = {}) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)}`
|
||||
: `${CLAWDHUB_API_BASE}/skills`;
|
||||
|
||||
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();
|
||||
return {
|
||||
items: data.items || [],
|
||||
nextCursor: data.nextCursor || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`;
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* ClawdHub integration module
|
||||
*
|
||||
* Provides skill browsing and installation from the ClawdHub registry.
|
||||
* https://clawdhub.com
|
||||
*/
|
||||
|
||||
export { scanClawdHub } from './scan.js';
|
||||
export { installSkillsFromClawdHub } from './install.js';
|
||||
export {
|
||||
fetchClawdHubSkills,
|
||||
fetchClawdHubSkillVersion,
|
||||
fetchClawdHubSkillInfo,
|
||||
downloadClawdHubSkill,
|
||||
} from './api.js';
|
||||
|
||||
/**
|
||||
* Check if a source string refers to ClawdHub
|
||||
* @param {string} source
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isClawdHubSource(source) {
|
||||
return typeof source === 'string' && source.startsWith('clawdhub:');
|
||||
}
|
||||
|
||||
/**
|
||||
* ClawdHub source identifier used in curated sources
|
||||
*/
|
||||
export const CLAWDHUB_SOURCE_ID = 'clawdhub';
|
||||
export const CLAWDHUB_SOURCE_STRING = 'clawdhub:registry';
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* ClawdHub skill installation
|
||||
*
|
||||
* Downloads skills from ClawdHub as ZIP files and extracts them
|
||||
* to the appropriate skill directory.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js';
|
||||
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
function validateSkillName(skillName) {
|
||||
if (typeof skillName !== 'string') return false;
|
||||
if (skillName.length < 1 || skillName.length > 64) return false;
|
||||
return SKILL_NAME_PATTERN.test(skillName);
|
||||
}
|
||||
|
||||
async function safeRm(dir) {
|
||||
try {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(dirPath) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
|
||||
if (scope === 'user') {
|
||||
return path.join(userSkillDir, skillName);
|
||||
}
|
||||
|
||||
if (!workingDirectory) {
|
||||
throw new Error('workingDirectory is required for project installs');
|
||||
}
|
||||
|
||||
return path.join(workingDirectory, '.opencode', 'skill', skillName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install skills from ClawdHub registry
|
||||
* @param {Object} options
|
||||
* @param {string} options.scope - 'user' or 'project'
|
||||
* @param {string} [options.workingDirectory] - Required for project scope
|
||||
* @param {string} options.userSkillDir - User skills directory
|
||||
* @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } }
|
||||
* @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll'
|
||||
* @param {Object} [options.conflictDecisions] - Per-skill conflict decisions
|
||||
* @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>}
|
||||
*/
|
||||
export async function installSkillsFromClawdHub({
|
||||
scope,
|
||||
workingDirectory,
|
||||
userSkillDir,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
} = {}) {
|
||||
if (scope !== 'user' && scope !== 'project') {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
|
||||
}
|
||||
|
||||
if (!userSkillDir) {
|
||||
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
|
||||
}
|
||||
|
||||
if (scope === 'project' && !workingDirectory) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
|
||||
}
|
||||
|
||||
const requestedSkills = Array.isArray(selections) ? selections : [];
|
||||
if (requestedSkills.length === 0) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
|
||||
}
|
||||
|
||||
// Build installation plans
|
||||
const skillPlans = requestedSkills.map((sel) => {
|
||||
const slug = sel.clawdhub?.slug || sel.skillDir;
|
||||
const version = sel.clawdhub?.version || 'latest';
|
||||
return {
|
||||
slug,
|
||||
version,
|
||||
installable: validateSkillName(slug),
|
||||
};
|
||||
});
|
||||
|
||||
// Check for conflicts before downloading
|
||||
const conflicts = [];
|
||||
for (const plan of skillPlans) {
|
||||
if (!plan.installable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const decision = conflictDecisions?.[plan.slug];
|
||||
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
|
||||
if (!decision && !hasAutoPolicy) {
|
||||
conflicts.push({ skillName: plan.slug, scope });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'conflicts',
|
||||
message: 'Some skills already exist in the selected scope',
|
||||
conflicts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const installed = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const plan of skillPlans) {
|
||||
if (!plan.installable) {
|
||||
skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve 'latest' version if needed
|
||||
let resolvedVersion = plan.version;
|
||||
if (resolvedVersion === 'latest') {
|
||||
try {
|
||||
const info = await fetchClawdHubSkillInfo(plan.slug);
|
||||
resolvedVersion = info.skill?.tags?.latest || info.latestVersion?.version || plan.version;
|
||||
} catch {
|
||||
// Fall back to 'latest' tag if info fetch fails
|
||||
resolvedVersion = 'latest';
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
|
||||
const exists = fs.existsSync(targetDir);
|
||||
|
||||
// Determine conflict resolution
|
||||
let decision = conflictDecisions?.[plan.slug] || null;
|
||||
if (!decision) {
|
||||
if (exists && conflictPolicy === 'skipAll') decision = 'skip';
|
||||
if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
||||
if (!exists) decision = 'overwrite'; // No conflict, proceed
|
||||
}
|
||||
|
||||
if (exists && decision === 'skip') {
|
||||
skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exists && decision === 'overwrite') {
|
||||
await safeRm(targetDir);
|
||||
}
|
||||
|
||||
// Download the skill ZIP
|
||||
const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion);
|
||||
|
||||
// Extract to a temp directory first for validation
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`));
|
||||
|
||||
try {
|
||||
const zip = new AdmZip(Buffer.from(zipBuffer));
|
||||
zip.extractAllTo(tempDir, true);
|
||||
|
||||
// Verify SKILL.md exists
|
||||
const skillMdPath = path.join(tempDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) {
|
||||
skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Move to target directory
|
||||
await ensureDir(path.dirname(targetDir));
|
||||
await fs.promises.rename(tempDir, targetDir);
|
||||
|
||||
installed.push({ skillName: plan.slug, scope });
|
||||
} catch (extractError) {
|
||||
await safeRm(tempDir);
|
||||
throw extractError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error);
|
||||
skipped.push({
|
||||
skillName: plan.slug,
|
||||
reason: error instanceof Error ? error.message : 'Failed to download or extract skill',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, installed, skipped };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* ClawdHub skill scanning
|
||||
*
|
||||
* Fetches all available skills from the ClawdHub registry
|
||||
* and transforms them into SkillsCatalogItem format.
|
||||
*/
|
||||
|
||||
import { fetchClawdHubSkills } from './api.js';
|
||||
|
||||
const MAX_PAGES = 20; // Safety limit to prevent infinite loops
|
||||
|
||||
/**
|
||||
* Scan ClawdHub registry for all available skills
|
||||
* @returns {Promise<{ ok: boolean, items?: Array, error?: Object }>}
|
||||
*/
|
||||
export async function scanClawdHub() {
|
||||
try {
|
||||
const allItems = [];
|
||||
let cursor = null;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const { items, nextCursor } = await fetchClawdHubSkills({ cursor });
|
||||
|
||||
for (const item of items) {
|
||||
const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0';
|
||||
|
||||
allItems.push({
|
||||
sourceId: 'clawdhub',
|
||||
repoSource: 'clawdhub:registry',
|
||||
repoSubpath: null,
|
||||
gitIdentityId: null,
|
||||
skillDir: item.slug,
|
||||
skillName: item.slug,
|
||||
frontmatterName: item.displayName || item.slug,
|
||||
description: item.summary || null,
|
||||
installable: true,
|
||||
warnings: [],
|
||||
// ClawdHub-specific metadata
|
||||
clawdhub: {
|
||||
slug: item.slug,
|
||||
version: latestVersion,
|
||||
displayName: item.displayName,
|
||||
owner: item.owner?.handle || null,
|
||||
downloads: item.stats?.downloads || 0,
|
||||
stars: item.stats?.stars || 0,
|
||||
versionsCount: item.stats?.versions || 1,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!nextCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = 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) {
|
||||
console.error('ClawdHub scan error:', error);
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'networkError',
|
||||
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,17 @@ export const CURATED_SKILLS_SOURCES = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
description: "Anthropic’s public skills repository",
|
||||
description: "Anthropic's public skills repository",
|
||||
source: 'anthropics/skills',
|
||||
defaultSubpath: 'skills',
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'clawdhub',
|
||||
label: 'ClawdHub',
|
||||
description: 'Community skill registry with vector search',
|
||||
source: 'clawdhub:registry',
|
||||
sourceType: 'clawdhub',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user