Initial release v1.0.0

Open-source marketplace for AI Agent skills.

Features:
- Next.js 15 web app with i18n (en/fa)
- CLI tool for skill installation (npx skillhub)
- GitHub crawler/indexer with multi-strategy discovery
- Security scanning for all indexed skills
- Self-hostable with Docker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-12 06:08:51 +03:30
commit 97b427831a
227 changed files with 48411 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries, type skills, isMeilisearchHealthy, searchSkills as meilisearchSearch } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { getCached, setCache, hashSearchParams, cacheKeys } from '@/lib/cache';
import { captureException, log } from '@/lib/sentry';
// Create database connection
const db = createDb();
type Skill = typeof skills.$inferSelect;
/**
* Restore skill ID from Meilisearch format
* Converts sanitized IDs back to original format:
* "anthropics__skills__pdf" -> "anthropics/skills/pdf"
* "bdmorin___dot_claude__git" -> "bdmorin/.claude/git"
* "user__repo_dot_name__skill" -> "user/repo.name/skill"
*/
function restoreIdFromMeili(meiliId: string): string {
return meiliId
.replace(/_dot_/g, '.') // _dot_ -> dot (do this FIRST)
.replace(/__/g, '/'); // double underscore -> slash
}
export async function GET(request: NextRequest) {
// Apply rate limiting (search is more expensive, use lower limit)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || undefined;
const platform = searchParams.get('platform') || undefined;
const category = searchParams.get('category') || undefined;
const format = searchParams.get('format') || 'skill.md';
const verified = searchParams.get('verified') === 'true';
// Parse and validate numeric parameters
const minStarsRaw = parseInt(searchParams.get('minStars') || '0');
const minStars = isNaN(minStarsRaw) || minStarsRaw < 0 ? 0 : minStarsRaw;
const sort = searchParams.get('sort') || 'stars';
const pageRaw = parseInt(searchParams.get('page') || '1');
const page = isNaN(pageRaw) || pageRaw < 1 ? 1 : pageRaw;
const limitRaw = parseInt(searchParams.get('limit') || '20');
const limit = isNaN(limitRaw) || limitRaw < 1 ? 20 : Math.min(limitRaw, 100); // Max 100 per page
const offset = (page - 1) * limit;
// Create cache key from search parameters
const searchHash = hashSearchParams({
q: query,
category,
platform,
format,
verified: verified ? 'true' : undefined,
sort,
page,
limit,
minStars: minStars > 0 ? minStars : undefined,
});
const cacheKey = cacheKeys.searchSkills(searchHash);
// Check cache first
const cached = await getCached<{
skills: Skill[];
total: number;
searchEngine: string;
}>(cacheKey);
if (cached) {
return NextResponse.json(
{
skills: cached.skills,
pagination: {
page,
limit,
total: cached.total,
totalPages: Math.ceil(cached.total / limit),
},
searchEngine: 'cache',
cachedFrom: cached.searchEngine,
},
{
headers: createRateLimitHeaders(rateLimitResult),
}
);
}
// Try Meilisearch for text search queries
if (query) {
const useMeilisearch = await isMeilisearchHealthy();
if (useMeilisearch) {
// Use Meilisearch for full-text search with relevance ranking
// Note: category filter not supported in Meilisearch, handled in PostgreSQL fallback
const meiliResult = await meilisearchSearch({
query,
filters: {
platforms: platform && platform !== 'all' ? [platform] : undefined,
minStars: minStars > 0 ? minStars : undefined,
verified: verified ? true : undefined,
},
sort: sort as 'stars' | 'downloads' | 'rating' | 'recent',
limit,
offset,
});
if (meiliResult) {
const skills = meiliResult.hits.map((hit) => ({
id: restoreIdFromMeili(hit.id),
name: hit.name,
description: hit.description,
githubOwner: hit.githubOwner,
githubRepo: hit.githubRepo,
githubStars: hit.githubStars,
downloadCount: hit.downloadCount,
securityScore: hit.securityScore,
securityStatus: null, // Not available in Meilisearch yet
rating: hit.rating,
ratingCount: null, // Not available in Meilisearch yet
isVerified: hit.isVerified,
compatibility: { platforms: hit.platforms },
}));
// Cache the result (5 minutes TTL)
await setCache(
cacheKey,
{
skills,
total: meiliResult.estimatedTotalHits,
searchEngine: 'meilisearch',
},
5 * 60
);
return NextResponse.json({
skills,
pagination: {
page,
limit,
total: meiliResult.estimatedTotalHits,
totalPages: Math.ceil(meiliResult.estimatedTotalHits / limit),
},
searchEngine: 'meilisearch',
processingTimeMs: meiliResult.processingTimeMs,
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
// If Meilisearch search failed, fall through to PostgreSQL
}
}
// Fall back to PostgreSQL search
// Map sort parameter to database column
const sortByMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
stars: 'stars',
downloads: 'downloads',
rating: 'rating',
recent: 'updated',
lastDownloaded: 'lastDownloaded',
security: 'stars', // Use stars as fallback
};
const sortBy = sortByMap[sort] || 'downloads';
// Build filter options for database query
const filterOptions = {
query,
category: category || undefined,
platform: platform && platform !== 'all' ? platform : undefined,
sourceFormat: format,
minStars,
verified: verified || undefined,
};
// Get paginated results directly from database (no in-memory filtering)
const paginatedResults = await skillQueries.search(db, {
...filterOptions,
limit,
offset,
sortBy,
sortOrder: 'desc',
});
// Get total count for pagination
const total = await skillQueries.count(db, filterOptions);
const skills = paginatedResults.map((skill: Skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
skillPath: skill.skillPath,
version: skill.version,
license: skill.license,
githubStars: skill.githubStars,
downloadCount: skill.downloadCount,
securityScore: skill.securityScore,
securityStatus: skill.securityStatus,
rating: skill.rating,
ratingCount: skill.ratingCount,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
updatedAt: skill.updatedAt,
}));
// Cache the result (5 minutes TTL)
await setCache(
cacheKey,
{
skills,
total,
searchEngine: 'postgresql',
},
5 * 60
);
return NextResponse.json({
skills,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
searchEngine: 'postgresql',
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
// Log and report error to Sentry
log.error('Error fetching skills', {
error: error instanceof Error ? error.message : String(error),
});
captureException(error, {
tags: { route: '/api/skills' },
extra: { searchParams: Object.fromEntries(request.nextUrl.searchParams) },
});
return NextResponse.json(
{ error: 'Failed to fetch skills' },
{ status: 500 }
);
}
}