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,203 @@
import type { Octokit } from '@octokit/rest';
import { TokenManager } from '../token-manager.js';
import { OctokitPool } from '../octokit-pool.js';
export interface RepoReference {
owner: string;
repo: string;
url: string;
}
/**
* Known awesome lists that contain skill repositories
*/
const KNOWN_AWESOME_LISTS = [
{ owner: 'travisvn', repo: 'awesome-claude-skills', readme: 'README.md' },
{ owner: 'VoltAgent', repo: 'awesome-claude-skills', readme: 'README.md' },
{ owner: 'ComposioHQ', repo: 'awesome-claude-skills', readme: 'README.md' },
{ owner: 'skillmatic-ai', repo: 'awesome-agent-skills', readme: 'README.md' },
{ owner: 'github', repo: 'awesome-copilot', readme: 'README.md' },
];
/**
* Awesome List Crawler Strategy
* Parses README files from curated "awesome" lists to discover skill repositories
*/
export class AwesomeListCrawler {
private octokitPool: OctokitPool;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.octokitPool = new OctokitPool(this.tokenManager);
}
private async getOctokit(): Promise<Octokit> {
return this.octokitPool.getBestInstance();
}
/**
* Get all known awesome lists
*/
getKnownLists() {
return KNOWN_AWESOME_LISTS;
}
/**
* Crawl all known awesome lists
*/
async crawlAllLists(): Promise<Map<string, RepoReference[]>> {
const results = new Map<string, RepoReference[]>();
for (const list of KNOWN_AWESOME_LISTS) {
try {
console.log(`Parsing awesome list: ${list.owner}/${list.repo}`);
const repos = await this.parseAwesomeList(list.owner, list.repo, list.readme);
results.set(`${list.owner}/${list.repo}`, repos);
console.log(` Found ${repos.length} repository references`);
} catch (error) {
console.warn(`Failed to parse ${list.owner}/${list.repo}:`, error);
results.set(`${list.owner}/${list.repo}`, []);
}
}
return results;
}
/**
* Parse an awesome list README to extract GitHub repository references
*/
async parseAwesomeList(
owner: string,
repo: string,
readmePath = 'README.md'
): Promise<RepoReference[]> {
try {
const octokit = await this.getOctokit();
const response = await octokit.repos.getContent({
owner,
repo,
path: readmePath,
});
if (!('content' in response.data)) {
return [];
}
const content = Buffer.from(response.data.content, 'base64').toString('utf-8');
return this.extractGitHubRepos(content);
} catch (error) {
console.warn(`Failed to fetch ${owner}/${repo}/${readmePath}:`, error);
return [];
}
}
/**
* Extract GitHub repository references from markdown content
*/
extractGitHubRepos(content: string): RepoReference[] {
const repos: RepoReference[] = [];
const seen = new Set<string>();
// Pattern to match GitHub URLs: github.com/owner/repo
// Supports various formats:
// - https://github.com/owner/repo
// - http://github.com/owner/repo
// - github.com/owner/repo
// - [text](https://github.com/owner/repo)
const patterns = [
/https?:\/\/github\.com\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_.-]+)/g,
/github\.com\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_.-]+)/g,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
const owner = match[1];
let repo = match[2];
// Clean up repo name (remove trailing slashes, .git, anchors, etc.)
repo = repo.replace(/\/$/, '').replace(/\.git$/, '').split('/')[0].split('#')[0];
// Skip invalid repos
if (!owner || !repo || repo.length === 0) continue;
// Skip common non-repo patterns
if (
repo === 'issues' ||
repo === 'pulls' ||
repo === 'blob' ||
repo === 'tree' ||
repo === 'raw' ||
repo === 'releases'
) {
continue;
}
const key = `${owner}/${repo}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
repos.push({
owner,
repo,
url: `https://github.com/${owner}/${repo}`,
});
}
}
return repos;
}
/**
* Discover additional awesome lists from GitHub search
*/
async discoverAwesomeLists(): Promise<RepoReference[]> {
const searchQueries = [
'awesome-claude-skills in:name',
'awesome-agent-skills in:name',
'awesome claude skills in:description',
'awesome ai skills in:description',
];
const allRepos: RepoReference[] = [];
const seen = new Set<string>();
for (const query of searchQueries) {
try {
const octokit = await this.getOctokit();
const response = await octokit.search.repos({
q: query,
sort: 'stars',
order: 'desc',
per_page: 30,
});
for (const repo of response.data.items) {
if (!repo.owner) continue;
const key = `${repo.owner.login}/${repo.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allRepos.push({
owner: repo.owner.login,
repo: repo.name,
url: repo.html_url,
});
}
} catch (error) {
console.warn(`Search query failed: ${query}`, error);
}
}
return allRepos;
}
}
export function createAwesomeListCrawler(tokenManager?: TokenManager | string): AwesomeListCrawler {
// Backward compatibility
if (typeof tokenManager === 'string') {
return new AwesomeListCrawler(new TokenManager([tokenManager]));
}
return new AwesomeListCrawler(tokenManager);
}

View File

@@ -0,0 +1,261 @@
import type { Octokit } from '@octokit/rest';
import { TokenManager } from '../token-manager.js';import { OctokitPool } from '../octokit-pool.js';
import type { SkillSource } from 'skillhub-core';
import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
/**
* Deep Scan Strategy
* Uses Git Trees API to recursively scan entire repositories for SKILL.md files
* Can discover skills that aren't found by code search
*/
export class DeepScanCrawler {
private octokitPool: OctokitPool;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.octokitPool = new OctokitPool(this.tokenManager);
}
private async getOctokit(): Promise<Octokit> {
return this.octokitPool.getBestInstance();
}
private getCurrentToken(): string {
return this.tokenManager.getBestToken();
}
/**
* Deep scan a repository for all SKILL.md files using Git Trees API
* This is more thorough than code search as it scans the entire repo
*/
async scanRepository(owner: string, repo: string): Promise<SkillSource[]> {
const skills: SkillSource[] = [];
try {
// Get repository info for default branch
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const repoInfo = await octokit.repos.get({ owner, repo });
this.octokitPool.updateStats(token, repoInfo.headers);
if (repoInfo.data.archived) {
console.log(` Skipping archived repo: ${owner}/${repo}`);
return [];
}
const defaultBranch = repoInfo.data.default_branch;
// Get the full tree recursively
const tree = await octokit.git.getTree({
owner,
repo,
tree_sha: defaultBranch,
recursive: 'true',
});
this.octokitPool.updateStats(token, tree.headers);
// Find all instruction files (SKILL.md, .cursorrules, .windsurfrules, AGENTS.md, etc.)
const instructionFiles = tree.data.tree.filter(
(item) => {
if (item.type !== 'blob' || !item.path) return false;
return INSTRUCTION_FILE_PATTERNS.some(pattern => {
if (pattern.rootOnly) return item.path === pattern.filename;
if (pattern.pathFilter) return item.path!.includes(pattern.pathFilter) && (item.path!.endsWith('/' + pattern.filename) || item.path === pattern.filename);
return item.path!.endsWith('/' + pattern.filename) || item.path === pattern.filename;
});
}
);
for (const file of instructionFiles) {
if (!file.path) continue;
const matchedPattern = INSTRUCTION_FILE_PATTERNS.find(pattern => {
if (pattern.rootOnly) return file.path === pattern.filename;
if (pattern.pathFilter) return file.path!.includes(pattern.pathFilter) && (file.path!.endsWith('/' + pattern.filename) || file.path === pattern.filename);
return file.path!.endsWith('/' + pattern.filename) || file.path === pattern.filename;
});
if (!matchedPattern) continue;
const escapedFilename = matchedPattern.filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const skillPath = file.path.replace(new RegExp(`/?${escapedFilename}$`), '') || '.';
skills.push({
owner,
repo,
path: skillPath,
branch: defaultBranch,
sourceFormat: matchedPattern.format,
});
}
if (skills.length > 0) {
console.log(` Found ${skills.length} skills in ${owner}/${repo}`);
}
return skills;
} catch (error) {
if (this.isNotFoundError(error)) {
return [];
}
if (this.isTruncatedError(error)) {
// Tree is truncated (>100k files), fall back to directory listing
console.log(` Repository ${owner}/${repo} is too large, using fallback scan`);
return this.fallbackScan(owner, repo);
}
throw error;
}
}
/**
* Fallback scan for large repositories - scan known skill directories
*/
private async fallbackScan(owner: string, repo: string): Promise<SkillSource[]> {
const skills: SkillSource[] = [];
const knownPaths = ['skills', '.claude/skills', '.github/skills', '.codex/skills', ''];
for (const basePath of knownPaths) {
try {
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const response = await octokit.repos.getContent({
owner,
repo,
path: basePath || '.',
});
this.octokitPool.updateStats(token, response.headers);
if (!Array.isArray(response.data)) continue;
// Check for SKILL.md at this level
const hasSkillMd = response.data.some((item) => item.name === 'SKILL.md');
if (hasSkillMd) {
skills.push({
owner,
repo,
path: basePath || '.',
branch: 'main',
sourceFormat: 'skill.md' as SourceFormat,
});
}
// Check subdirectories for SKILL.md
const dirs = response.data.filter((item) => item.type === 'dir');
for (const dir of dirs) {
try {
const subOctokit = await this.getOctokit();
const subToken = this.getCurrentToken();
const subDir = await subOctokit.repos.getContent({
owner,
repo,
path: dir.path,
});
this.octokitPool.updateStats(subToken, subDir.headers);
if (Array.isArray(subDir.data)) {
const hasSubSkillMd = subDir.data.some((item) => item.name === 'SKILL.md');
if (hasSubSkillMd) {
skills.push({
owner,
repo,
path: dir.path,
branch: 'main',
sourceFormat: 'skill.md' as SourceFormat,
});
}
}
} catch {
// Skip inaccessible directories
}
}
} catch {
// Path doesn't exist, continue
}
}
// Check for root-level instruction files (.cursorrules, .windsurfrules, AGENTS.md)
const rootFiles = INSTRUCTION_FILE_PATTERNS.filter(p => p.format !== 'skill.md');
for (const pattern of rootFiles) {
try {
const filePath = pattern.pathFilter ? `${pattern.pathFilter}${pattern.filename}` : pattern.filename;
const fileContent = await this.getFileContent(owner, repo, filePath);
if (fileContent && fileContent.length >= pattern.minContentLength) {
skills.push({
owner,
repo,
path: '.',
branch: 'main',
sourceFormat: pattern.format,
});
}
} catch {
// File doesn't exist
}
}
return skills;
}
/**
* Scan multiple repositories
*/
async scanRepositories(
repos: Array<{ owner: string; repo: string }>
): Promise<Map<string, SkillSource[]>> {
const results = new Map<string, SkillSource[]>();
for (const { owner, repo } of repos) {
try {
console.log(`Deep scanning: ${owner}/${repo}`);
const skills = await this.scanRepository(owner, repo);
results.set(`${owner}/${repo}`, skills);
} catch (error) {
console.warn(` Failed to scan ${owner}/${repo}:`, error);
results.set(`${owner}/${repo}`, []);
}
}
return results;
}
/**
* Get file content from a repository
*/
async getFileContent(owner: string, repo: string, path: string): Promise<string | null> {
try {
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const response = await octokit.repos.getContent({
owner,
repo,
path,
});
this.octokitPool.updateStats(token, response.headers);
if ('content' in response.data && response.data.type === 'file') {
return Buffer.from(response.data.content, 'base64').toString('utf-8');
}
return null;
} catch {
return null;
}
}
private isNotFoundError(error: unknown): boolean {
return (
error instanceof Error && 'status' in error && (error as { status: number }).status === 404
);
}
private isTruncatedError(error: unknown): boolean {
// Git Trees API returns truncated: true for repos >100k files
return error instanceof Error && error.message.includes('truncated');
}
}
export function createDeepScanCrawler(tokenManager?: TokenManager | string): DeepScanCrawler {
if (typeof tokenManager === 'string') {
return new DeepScanCrawler(new TokenManager([tokenManager]));
}
return new DeepScanCrawler(tokenManager);
}

View File

@@ -0,0 +1,194 @@
import type { Octokit } from '@octokit/rest';
import { TokenManager } from '../token-manager.js';import { OctokitPool } from '../octokit-pool.js';
export interface ForkInfo {
owner: string;
repo: string;
stars: number;
updatedAt: string;
isArchived: boolean;
defaultBranch: string;
}
/**
* Fork Network Strategy
* Discovers skills by traversing fork networks of known skill repositories
* Forks often contain modified or additional skills
*/
export class ForkNetworkCrawler {
private octokitPool: OctokitPool;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.octokitPool = new OctokitPool(this.tokenManager);
}
private async getOctokit(): Promise<Octokit> {
return this.octokitPool.getBestInstance();
}
private getCurrentToken(): string {
return this.tokenManager.getBestToken();
}
/**
* Get all forks of a repository
*/
async getForks(owner: string, repo: string, maxPages = 10): Promise<ForkInfo[]> {
const forks: ForkInfo[] = [];
let page = 1;
while (page <= maxPages) {
try {
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const response = await octokit.repos.listForks({
owner,
repo,
sort: 'stargazers',
per_page: 100,
page,
});
this.octokitPool.updateStats(token, response.headers);
if (response.data.length === 0) break;
for (const fork of response.data) {
// Skip old/abandoned forks (not updated in 1 year)
const updatedAt = fork.updated_at ? new Date(fork.updated_at) : new Date(0);
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
if (updatedAt < oneYearAgo) continue;
forks.push({
owner: fork.owner.login,
repo: fork.name,
stars: fork.stargazers_count ?? 0,
updatedAt: fork.updated_at ?? new Date().toISOString(),
isArchived: fork.archived ?? false,
defaultBranch: fork.default_branch ?? 'main',
});
}
if (response.data.length < 100) break;
page++;
} catch (error) {
console.warn(`Failed to get forks page ${page} for ${owner}/${repo}:`, error);
break;
}
}
return forks;
}
/**
* Get forks for multiple seed repositories
*/
async discoverFromSeedRepos(
seedRepos: Array<{ owner: string; repo: string }>
): Promise<ForkInfo[]> {
const allForks: ForkInfo[] = [];
const seen = new Set<string>();
for (const seed of seedRepos) {
try {
console.log(`Fetching forks of ${seed.owner}/${seed.repo}...`);
const forks = await this.getForks(seed.owner, seed.repo);
for (const fork of forks) {
const key = `${fork.owner}/${fork.repo}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allForks.push(fork);
}
console.log(` Found ${forks.length} active forks`);
} catch (error) {
console.warn(`Failed to get forks for ${seed.owner}/${seed.repo}:`, error);
}
}
console.log(`Total unique forks discovered: ${allForks.length}`);
return allForks;
}
/**
* Get parent/source repository (for finding the original if we have a fork)
*/
async getParentRepo(owner: string, repo: string): Promise<{ owner: string; repo: string } | null> {
try {
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const response = await octokit.repos.get({ owner, repo });
this.octokitPool.updateStats(token, response.headers);
if (response.data.fork && response.data.parent) {
return {
owner: response.data.parent.owner.login,
repo: response.data.parent.name,
};
}
return null;
} catch {
return null;
}
}
/**
* Get all repositories in the same network (parent + siblings)
*/
async getNetworkRepos(owner: string, repo: string): Promise<ForkInfo[]> {
const repos: ForkInfo[] = [];
const seen = new Set<string>();
// Get parent if this is a fork
const parent = await this.getParentRepo(owner, repo);
const rootOwner = parent?.owner || owner;
const rootRepo = parent?.repo || repo;
// Add root repo
try {
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const rootInfo = await octokit.repos.get({ owner: rootOwner, repo: rootRepo });
this.octokitPool.updateStats(token, rootInfo.headers);
const rootKey = `${rootOwner}/${rootRepo}`.toLowerCase();
seen.add(rootKey);
repos.push({
owner: rootOwner,
repo: rootRepo,
stars: rootInfo.data.stargazers_count,
updatedAt: rootInfo.data.updated_at,
isArchived: rootInfo.data.archived,
defaultBranch: rootInfo.data.default_branch,
});
} catch {
// Root not accessible
}
// Get all forks of root
const forks = await this.getForks(rootOwner, rootRepo);
for (const fork of forks) {
const key = `${fork.owner}/${fork.repo}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
repos.push(fork);
}
return repos;
}
}
export function createForkNetworkCrawler(tokenManager?: TokenManager | string): ForkNetworkCrawler {
if (typeof tokenManager === 'string') {
return new ForkNetworkCrawler(new TokenManager([tokenManager]));
}
return new ForkNetworkCrawler(tokenManager);
}

View File

@@ -0,0 +1,292 @@
/**
* Strategy Orchestrator
* Coordinates multiple discovery strategies for comprehensive skill discovery
*/
import { createAwesomeListCrawler } from './awesome-list.js';
import { createTopicSearchCrawler } from './topic-search.js';
import { createDeepScanCrawler } from './deep-scan.js';
import { createForkNetworkCrawler } from './fork-network.js';
import { TokenManager } from '../token-manager.js';
import type { SkillSource } from 'skillhub-core';
export interface DiscoveryResult {
source: string;
repos: Array<{
owner: string;
repo: string;
stars?: number;
discoveredVia: string;
}>;
skills: SkillSource[];
}
export interface DiscoveryStats {
totalReposDiscovered: number;
totalSkillsFound: number;
byStrategy: {
[key: string]: {
repos: number;
skills: number;
};
};
duration: number;
}
/**
* Strategy Orchestrator - coordinates all discovery strategies
*/
export class StrategyOrchestrator {
private awesomeCrawler: ReturnType<typeof createAwesomeListCrawler>;
private topicCrawler: ReturnType<typeof createTopicSearchCrawler>;
private deepScanCrawler: ReturnType<typeof createDeepScanCrawler>;
private forkCrawler: ReturnType<typeof createForkNetworkCrawler>;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.awesomeCrawler = createAwesomeListCrawler(this.tokenManager);
this.topicCrawler = createTopicSearchCrawler(this.tokenManager);
this.deepScanCrawler = createDeepScanCrawler(this.tokenManager);
this.forkCrawler = createForkNetworkCrawler(this.tokenManager);
}
/**
* Run awesome list discovery
*/
async runAwesomeListStrategy(): Promise<DiscoveryResult> {
console.log('\n=== Running Awesome List Strategy ===');
const startTime = Date.now();
const listResults = await this.awesomeCrawler.crawlAllLists();
const repos: DiscoveryResult['repos'] = [];
const seen = new Set<string>();
for (const repoRefs of listResults.values()) {
for (const ref of repoRefs) {
const key = `${ref.owner}/${ref.repo}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
repos.push({
owner: ref.owner,
repo: ref.repo,
discoveredVia: 'awesome-list',
});
}
}
console.log(`Awesome list strategy completed in ${Date.now() - startTime}ms`);
console.log(`Discovered ${repos.length} repositories from ${listResults.size} lists`);
return {
source: 'awesome-list',
repos,
skills: [],
};
}
/**
* Run topic search discovery
*/
async runTopicSearchStrategy(): Promise<DiscoveryResult> {
console.log('\n=== Running Topic Search Strategy ===');
const startTime = Date.now();
const repoResults = await this.topicCrawler.discoverAll();
const repos = repoResults.map((r) => ({
owner: r.owner,
repo: r.repo,
stars: r.stars,
discoveredVia: 'topic-search',
}));
console.log(`Topic search strategy completed in ${Date.now() - startTime}ms`);
console.log(`Discovered ${repos.length} repositories`);
return {
source: 'topic-search',
repos,
skills: [],
};
}
/**
* Run deep scan on discovered repositories
*/
async runDeepScanStrategy(
repos: Array<{ owner: string; repo: string }>
): Promise<DiscoveryResult> {
console.log('\n=== Running Deep Scan Strategy ===');
const startTime = Date.now();
const scanResults = await this.deepScanCrawler.scanRepositories(repos);
const allSkills: SkillSource[] = [];
for (const skills of scanResults.values()) {
allSkills.push(...skills);
}
console.log(`Deep scan strategy completed in ${Date.now() - startTime}ms`);
console.log(`Found ${allSkills.length} skills in ${repos.length} repositories`);
return {
source: 'deep-scan',
repos: [],
skills: allSkills,
};
}
/**
* Run fork network discovery
*/
async runForkNetworkStrategy(
seedRepos: Array<{ owner: string; repo: string }>
): Promise<DiscoveryResult> {
console.log('\n=== Running Fork Network Strategy ===');
const startTime = Date.now();
const forks = await this.forkCrawler.discoverFromSeedRepos(seedRepos);
const repos = forks
.filter((f) => !f.isArchived)
.map((f) => ({
owner: f.owner,
repo: f.repo,
stars: f.stars,
discoveredVia: 'fork-network',
}));
console.log(`Fork network strategy completed in ${Date.now() - startTime}ms`);
console.log(`Discovered ${repos.length} active forks`);
return {
source: 'fork-network',
repos,
skills: [],
};
}
/**
* Run all strategies and return combined results
*/
async runAllStrategies(): Promise<{
repos: Array<{
owner: string;
repo: string;
stars?: number;
discoveredVia: string;
}>;
stats: DiscoveryStats;
}> {
const startTime = Date.now();
const stats: DiscoveryStats = {
totalReposDiscovered: 0,
totalSkillsFound: 0,
byStrategy: {},
duration: 0,
};
const allRepos: Map<string, {
owner: string;
repo: string;
stars?: number;
discoveredVia: string;
}> = new Map();
// 1. Awesome lists (fast, high yield)
try {
const awesomeResult = await this.runAwesomeListStrategy();
stats.byStrategy['awesome-list'] = {
repos: awesomeResult.repos.length,
skills: 0,
};
for (const repo of awesomeResult.repos) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (!allRepos.has(key)) {
allRepos.set(key, repo);
}
}
} catch (error) {
console.error('Awesome list strategy failed:', error);
}
// 2. Topic search (medium speed, medium yield)
try {
const topicResult = await this.runTopicSearchStrategy();
stats.byStrategy['topic-search'] = {
repos: topicResult.repos.length,
skills: 0,
};
for (const repo of topicResult.repos) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (!allRepos.has(key)) {
allRepos.set(key, repo);
}
}
} catch (error) {
console.error('Topic search strategy failed:', error);
}
// 3. Fork network (for high-star repos from above)
try {
const highStarRepos = Array.from(allRepos.values())
.filter((r) => (r.stars || 0) >= 10)
.slice(0, 50); // Top 50 repos by stars
if (highStarRepos.length > 0) {
const forkResult = await this.runForkNetworkStrategy(
highStarRepos.map((r) => ({ owner: r.owner, repo: r.repo }))
);
stats.byStrategy['fork-network'] = {
repos: forkResult.repos.length,
skills: 0,
};
for (const repo of forkResult.repos) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (!allRepos.has(key)) {
allRepos.set(key, repo);
}
}
}
} catch (error) {
console.error('Fork network strategy failed:', error);
}
stats.totalReposDiscovered = allRepos.size;
stats.duration = Date.now() - startTime;
console.log('\n=== Discovery Summary ===');
console.log(`Total unique repositories: ${allRepos.size}`);
console.log(`Duration: ${(stats.duration / 1000).toFixed(1)}s`);
console.log('By strategy:');
for (const [strategy, data] of Object.entries(stats.byStrategy)) {
console.log(` ${strategy}: ${data.repos} repos`);
}
return {
repos: Array.from(allRepos.values()),
stats,
};
}
}
export function createStrategyOrchestrator(tokenManager?: TokenManager | string): StrategyOrchestrator {
if (typeof tokenManager === 'string') {
return new StrategyOrchestrator(new TokenManager([tokenManager]));
}
return new StrategyOrchestrator(tokenManager);
}
// Re-export strategy creators
export { createAwesomeListCrawler } from './awesome-list.js';
export { createTopicSearchCrawler } from './topic-search.js';
export { createDeepScanCrawler } from './deep-scan.js';
export { createForkNetworkCrawler } from './fork-network.js';
// Re-export types
export type { RepoReference } from './awesome-list.js';
export type { RepoResult } from './topic-search.js';
export type { ForkInfo } from './fork-network.js';

View File

@@ -0,0 +1,314 @@
import type { Octokit } from '@octokit/rest';
import { TokenManager } from '../token-manager.js';import { OctokitPool } from '../octokit-pool.js';
export interface RepoResult {
owner: string;
repo: string;
stars: number;
forks: number;
description: string | null;
topics: string[];
defaultBranch: string;
isArchived: boolean;
updatedAt: string;
}
/**
* Topics that indicate a repository might contain skills
* Includes both compound topics (claude-skills) and simple topics (skill)
*/
const SKILL_TOPICS = [
// Compound skill topics
'claude-skills',
'agent-skills',
'ai-skills',
'claude-code',
'codex-skills',
'copilot-skills',
'skill-md',
'anthropic-skills',
'claude-code-skills',
'ai-agent-skills',
// Simple skill topics (for repos like openclaw/skills with topic "skill")
'skill',
'skills',
// Platform-specific
'clawdhub',
'clawdbot',
'skillhub',
// AI agent related
'ai-agent',
'claude-agent',
'anthropic',
'llm-skills',
'mcp-skills',
// Cursor-specific
'cursor-rules',
'cursorrules',
// Windsurf-specific
'windsurf-rules',
'windsurfrules',
// Copilot-specific
'copilot-instructions',
'github-copilot',
// Codex-specific
'codex-agent',
'openai-codex',
];
/**
* Search queries for repository descriptions and READMEs
*/
const REPO_SEARCH_QUERIES = [
'SKILL.md in:readme',
'claude skills in:description',
'agent skills in:description',
'"agent skill" in:readme',
'claude code skill in:readme',
'codex skill in:readme',
'skill.md file in:readme',
// Additional queries for platforms like clawdhub
'clawdhub in:description',
'clawdbot in:description',
'claude code skills in:description',
'agent skill in:name',
'skills archive in:description',
'anthropic skills in:description',
'.cursorrules in:readme',
'cursor rules in:description',
'AGENTS.md in:readme',
'codex agents in:description',
'copilot-instructions in:readme',
'windsurf rules in:description',
'.windsurfrules in:readme',
];
/**
* Topic Search Strategy
* Discovers repositories using GitHub's topic and description search
*/
export class TopicSearchCrawler {
private octokitPool: OctokitPool;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.octokitPool = new OctokitPool(this.tokenManager);
}
private async getOctokit(): Promise<Octokit> {
return this.octokitPool.getBestInstance();
}
private getCurrentToken(): string {
return this.tokenManager.getBestToken();
}
/**
* Search by all known skill-related topics
*/
async searchByTopics(): Promise<RepoResult[]> {
const allRepos: RepoResult[] = [];
const seen = new Set<string>();
console.log(`Searching by ${SKILL_TOPICS.length} skill-related topics...`);
for (const topic of SKILL_TOPICS) {
try {
console.log(` Searching topic: ${topic}`);
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const response = await octokit.search.repos({
q: `topic:${topic}`,
sort: 'stars',
order: 'desc',
per_page: 100,
});
this.octokitPool.updateStats(token, response.headers);
console.log(` Found ${response.data.total_count} repos`);
for (const repo of response.data.items) {
if (!repo.owner) continue;
const key = `${repo.owner.login}/${repo.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allRepos.push({
owner: repo.owner.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,
description: repo.description,
topics: repo.topics || [],
defaultBranch: repo.default_branch,
isArchived: repo.archived,
updatedAt: repo.updated_at,
});
}
// Paginate if more results
if (response.data.total_count > 100) {
const additionalRepos = await this.paginateSearch(`topic:${topic}`, seen, 5);
allRepos.push(...additionalRepos);
}
} catch (error) {
console.warn(` Topic search failed for ${topic}:`, error);
}
}
console.log(`Topic search found ${allRepos.length} unique repositories`);
return allRepos;
}
/**
* Search by description and README content
*/
async searchByDescription(): Promise<RepoResult[]> {
const allRepos: RepoResult[] = [];
const seen = new Set<string>();
console.log(`Searching by ${REPO_SEARCH_QUERIES.length} description queries...`);
for (const query of REPO_SEARCH_QUERIES) {
try {
console.log(` Query: ${query}`);
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const response = await octokit.search.repos({
q: query,
sort: 'stars',
order: 'desc',
per_page: 100,
});
this.octokitPool.updateStats(token, response.headers);
console.log(` Found ${response.data.total_count} repos`);
for (const repo of response.data.items) {
if (!repo.owner) continue;
const key = `${repo.owner.login}/${repo.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allRepos.push({
owner: repo.owner.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,
description: repo.description,
topics: repo.topics || [],
defaultBranch: repo.default_branch,
isArchived: repo.archived,
updatedAt: repo.updated_at,
});
}
// Paginate if more results
if (response.data.total_count > 100) {
const additionalRepos = await this.paginateSearch(query, seen, 5);
allRepos.push(...additionalRepos);
}
} catch (error) {
console.warn(` Description search failed for "${query}":`, error);
}
}
console.log(`Description search found ${allRepos.length} unique repositories`);
return allRepos;
}
/**
* Paginate through search results
*/
private async paginateSearch(
query: string,
seen: Set<string>,
maxPages: number
): Promise<RepoResult[]> {
const repos: RepoResult[] = [];
for (let page = 2; page <= maxPages; page++) {
try {
const octokit = await this.getOctokit();
const token = this.getCurrentToken();
const response = await octokit.search.repos({
q: query,
sort: 'stars',
order: 'desc',
per_page: 100,
page,
});
this.octokitPool.updateStats(token, response.headers);
if (response.data.items.length === 0) break;
for (const repo of response.data.items) {
if (!repo.owner) continue;
const key = `${repo.owner.login}/${repo.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
repos.push({
owner: repo.owner.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,
description: repo.description,
topics: repo.topics || [],
defaultBranch: repo.default_branch,
isArchived: repo.archived,
updatedAt: repo.updated_at,
});
}
} catch (error) {
// Stop on 422 (beyond 1000 results)
if (this.isBeyondResultsLimit(error)) break;
throw error;
}
}
return repos;
}
/**
* Run all topic and description searches
*/
async discoverAll(): Promise<RepoResult[]> {
const topicRepos = await this.searchByTopics();
const descRepos = await this.searchByDescription();
// Merge and deduplicate
const seen = new Set<string>();
const allRepos: RepoResult[] = [];
for (const repo of [...topicRepos, ...descRepos]) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allRepos.push(repo);
}
console.log(`Total unique repositories discovered: ${allRepos.length}`);
return allRepos;
}
private isBeyondResultsLimit(error: unknown): boolean {
return (
error instanceof Error &&
'status' in error &&
(error as { status: number }).status === 422
);
}
}
export function createTopicSearchCrawler(tokenManager?: TokenManager | string): TopicSearchCrawler {
if (typeof tokenManager === 'string') {
return new TopicSearchCrawler(new TokenManager([tokenManager]));
}
return new TopicSearchCrawler(tokenManager);
}