feat: replace GitHub topic search with curated repos for skills aggregation

Replace blind GitHub Search API topic-based discovery with a curated list of
7 high-quality skill repositories. Fetch SKILL.md frontmatter for real names
and descriptions, deduplicate by slug, and cap output at 300 skills.

- Rewrite fetch-featured-skills.mjs: curated repos list, Repos API + Trees API
  + Contents API, .env auto-loading, SKILL.md name/description extraction
- Add empty-result fallback in featured_skills.rs (skip to bundled JSON)
- Add .env.example for GITHUB_TOKEN
- Move requirements doc to docs/releases/v0.3.1/

API calls reduced from ~412 to ~314, output quality significantly improved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
--global 2026-03-20 20:21:55 +08:00
parent 3526d91da6
commit 441e3bd72c
5 changed files with 5616 additions and 748 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
GITHUB_TOKEN=ghp_your_github_token_here

View File

@ -0,0 +1,234 @@
# 需求Skills 聚合数据源升级 — 精选仓库列表方案
## 背景
Skills Hub 应用的"探索"功能依赖 `featured-skills.json` 提供精选技能列表。早期方案通过 GitHub Search API 搜索 `claude-code-skill` 等 topic 标签自动发现仓库,但属于盲目搜索,质量不可控,噪音多。
**新方案**维护一份精选仓库列表Curated Repos直接从已知的高质量仓库中获取 skills。质量可控、API 调用极少、维护简单。
## 目标
重写 `scripts/fetch-featured-skills.mjs` 脚本,从精选仓库列表直接获取元数据并深入检测 skill 目录结构,为每个 skill 生成独立条目,最终输出 `featured-skills.json`(最多 300 条)。保持现有的 GitHub Actions 定时更新机制不变。
## 架构(保持不变)
```
skills-desktop-app/
├── featured-skills.json # 精选技能数据(应用内嵌 fallback + 在线更新源)
├── scripts/
│ └── fetch-featured-skills.mjs # 聚合脚本(需重写)
└── .github/workflows/
└── update-featured-skills.yml # 每日定时运行(保持不变)
```
## 数据源 — 精选仓库列表
从 GitHub Topic 盲目搜索改为直接指定高质量仓库:
| 仓库 | Stars | 说明 |
|------|-------|------|
| `anthropics/skills` | ~96k | Anthropic 官方 Agent Skills |
| `sickn33/antigravity-awesome-skills` | ~24.7k | 1000+ 社区 skills 集合 |
| `K-Dense-AI/claude-scientific-skills` | ~15.3k | 170+ 科学研究 skills |
| `travisvn/awesome-claude-skills` | ~9.1k | 精选 Claude skills 列表 |
| `VoltAgent/awesome-agent-skills` | ~8.4k | 500+ Agent skills |
| `anthropics/knowledge-work-plugins` | ~7.7k | 官方知识工作插件 |
| `alirezarezvani/claude-skills` | ~5.2k | 192+ 社区 skills |
列表定义在脚本顶部 `CURATED_REPOS` 常量中,新增/移除仓库只需编辑此数组。
## 脚本重写逻辑
### 1. 数据采集(仓库元数据)
通过 GitHub Repos API 逐个获取精选仓库的元数据:
```
GET /repos/{owner}/{repo}
```
返回:`full_name`, `description`, `stargazers_count`, `topics[]`, `updated_at`, `html_url`, `default_branch`
认证:**必须**使用环境变量 `GITHUB_TOKEN`,脚本启动时校验。
失败处理:获取失败的仓库跳过(打印警告),不影响其余仓库。
### 2. Skill 检测(仓库内深入扫描)
使用 GitHub Git Trees API 获取仓库目录结构(单次请求,`recursive=1`
```
GET /repos/{owner}/{repo}/git/trees/{default_branch}?recursive=1
```
#### Skill 目录扫描规则(与应用端 `installer.rs` 保持一致)
扫描以下基础路径下的子目录:
```
skills/
skills/.curated/
skills/.experimental/
skills/.system/
.claude/skills/
```
以及**根目录的直接子目录**(排除 `skills/`、`.claude/`、`.git/` 等特殊目录)。
#### 新增:`.claude-plugin/plugin.json` 检测
为兼容 `anthropics/knowledge-work-plugins` 等使用插件格式的仓库,增加检测规则:
- 根级子目录包含 `.claude-plugin/plugin.json` 文件 → 视为有效 skill
#### Skill 判定条件
一个目录被视为有效 skill需满足以下任一条件
- 目录内存在 `SKILL.md` 文件
- 目录位于 `.claude/skills/` 路径下(即使没有 `SKILL.md`
- 目录内存在 `.claude-plugin/plugin.json` 文件(插件格式)
#### 单 skill 仓库
如果仓库根目录本身就是一个 skill根目录有 `SKILL.md`),且没有检测到子目录 skill则整个仓库视为单 skill`source_url` 指向仓库根路径。
#### Skill 名称与描述
- **名称**:从 skill 目录名生成kebab-case → Title Case
- **描述**:使用仓库的 `description` 字段(同仓库内所有 skill 共享)
> 注:不使用 Contents API 获取 SKILL.md 内容,避免大量 API 调用。目录名 + 仓库 description 已满足展示需求。
### 3. 自动分类
基于仓库 `topics[]``description` 关键词匹配(分类继承自仓库,同仓库内所有 skill 共享分类):
| 关键词 | 分类 |
|--------|------|
| browser, automation, playwright, puppeteer | browser-automation |
| security, audit, vulnerability, pentest | security |
| devops, deploy, infra, docker, kubernetes | devops |
| marketing, seo, ads, advertising | marketing |
| database, sql, postgres, mongo | database |
| git, github, pr, code-review | development |
| ai, llm, agent, model | ai-assistant |
| 以上都不匹配 | general |
### 4. 排序与截取
1. 按仓库 `stargazers_count` 降序,同星数按 skill 名称字母序
2. **截取前 300 条**`MAX_SKILLS = 300`),避免输出过大
### 5. 输出
生成 `featured-skills.json`**向前兼容现有数据结构**
```json
{
"updated_at": "2026-03-19T00:00:00Z",
"total": 300,
"categories": ["general", "browser-automation", "security", "devops", ...],
"skills": [
{
"slug": "commit",
"name": "Conventional Commit",
"summary": "Generate conventional commit messages...",
"downloads": 0,
"stars": 96000,
"category": "development",
"tags": ["claude-code-skill", "git", "commit"],
"source_url": "https://github.com/anthropics/skills/tree/main/skills/commit",
"updated_at": "2026-03-17T15:10:09Z"
}
]
}
```
#### 向前兼容策略
新格式**必须保留现有字段**,确保后端 `FeaturedSkillRaw` 和前端 `FeaturedSkillDto` 无需任何改动即可解析新数据:
| 字段 | 现有 | 新版 | 兼容处理 |
|------|------|------|----------|
| `slug` | ✅ | ✅ | 不变 |
| `name` | ✅ | ✅ | 不变 |
| `summary` | ✅ | ✅ | 不变 |
| `downloads` | ✅ | ✅ | **保留,固定为 0**(无真实数据源) |
| `stars` | ✅ | ✅ | 填入 GitHub 真实 star 数 |
| `source_url` | ✅ | ✅ | 精确到 skill 目录的路径 |
| `category` | ❌ | ✅ 新增 | 后端 `#[serde(default)]` 自动忽略未知字段 |
| `tags` | ❌ | ✅ 新增 | 同上 |
| `updated_at`skill 级) | ❌ | ✅ 新增 | 同上 |
**结论**:脚本输出格式升级后,后端和前端代码**零改动**即可正常工作。
#### 字段说明
- `slug`skill 目录名(单 skill 仓库则为仓库名)
- `name`:基于 skill 目录名生成kebab-case → Title Case
- `summary`:仓库 `description`(同仓库内所有 skill 共享)
- `downloads`:固定为 `0`(向前兼容,无真实数据源)
- `stars`:所属仓库的 GitHub star 数
- `category`:基于仓库 topics/description 的自动分类结果
- `tags`:仓库 `topics[]`(最多取 5 个)
- `source_url`**精确到 skill 目录的 GitHub URL**,格式为 `https://github.com/{owner}/{repo}/tree/{branch}/{skill-path}`;单 skill 仓库则为 `https://github.com/{owner}/{repo}`
- `updated_at`:仓库最后更新时间
## API 用量估算
| 阶段 | API | 请求数 | 说明 |
|------|-----|--------|------|
| 获取仓库元数据 | Repos API | 7 | 每个精选仓库 1 次 |
| 获取目录树 | Git Trees API | 7 | 每个仓库 1 次 |
| **合计** | | **14** | |
对比旧方案Topic 搜索)的 ~412 次请求,新方案仅需 14 次,**几乎不可能触发速率限制**。
GitHub API 限额已认证5000 次/小时。即使未认证60 次/小时)也完全够用,但仍建议使用 token 以确保稳定性。
## GitHub Actions 配置
保持现有 `.github/workflows/update-featured-skills.yml` 不变:
```yaml
- name: Fetch featured skills
run: node scripts/fetch-featured-skills.mjs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
## Skills Hub 应用端适配
**后端和前端代码无需任何改动**(向前兼容):
- `FeaturedSkillRaw` / `FeaturedSkillDto` 结构体不变
- 新增的 `category`、`tags`、`updated_at` 等字段被 serde 自动忽略
- 内嵌 fallback`featured-skills.json`)随脚本运行自动更新
### 后续可选增强(独立需求)
1. 后端 DTO 新增 `category`、`tags` 字段以支持前端展示
2. 探索页面增加按分类筛选
3. 排序改为按星数排序
## 技术要点
- **精选仓库列表**:质量可控,新增仓库只需编辑 `CURATED_REPOS` 数组
- **极低 API 用量**14 次请求 vs 旧方案 412 次,无速率限制风险
- **零外部依赖**:仅依赖 GitHub API公开、稳定、有 SLA
- **Skill 粒度聚合**:每条记录精确到仓库内具体 skill 目录
- **与应用检测逻辑一致**skill 扫描规则与 `installer.rs` 保持同步
- **新增插件格式支持**:兼容 `.claude-plugin/plugin.json` 结构
- **数量上限**:最多 300 条,按星数排序取 top
- **项目内维护**脚本、数据、CI 全部在 skills-desktop-app 仓库内
## 变更清单
| 文件 | 操作 |
|------|------|
| `scripts/fetch-featured-skills.mjs` | 重写(精选仓库列表 + Repos API + Trees API |
| `featured-skills.json` | 内容更新精选仓库来源≤300 条) |
| `docs/requirements/skills-aggregation-repo.md` | 更新需求文档 |
| `.github/workflows/update-featured-skills.yml` | **无需改动** |
| 后端代码 | **无需改动**(向前兼容) |
| 前端代码 | **无需改动**(向前兼容) |

File diff suppressed because it is too large Load Diff

View File

@ -1,94 +1,426 @@
#!/usr/bin/env node
/**
* Fetches popular skills from ClawHub API, resolves each skill's owner
* via detail API, and produces featured-skills.json with GitHub source URLs.
* Aggregates AI Agent Skills from a curated list of high-quality GitHub repositories.
*
* Fetches metadata and directory trees for each curated repo, detects individual skills
* within each repo, and outputs featured-skills.json with one entry per skill.
*
* API budget: ~14 requests total (7 Repos API + 7 Trees API).
*
* Requires: GITHUB_TOKEN environment variable.
*/
const CLAWHUB_LIST_API = 'https://clawhub.ai/api/v1/skills?sort=downloads&limit=100'
const CLAWHUB_DETAIL_API = 'https://clawhub.ai/api/v1/skills'
const GITHUB_REPO = 'openclaw/skills'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
// Load .env file
const envPath = resolve(import.meta.dirname, '..', '.env')
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const idx = trimmed.indexOf('=')
if (idx === -1) continue
const key = trimmed.slice(0, idx).trim()
const value = trimmed.slice(idx + 1).trim()
if (!process.env[key]) process.env[key] = value
}
}
const OUTPUT_FILE = 'featured-skills.json'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// Curated high-quality skill repositories (ordered by stars desc)
const CURATED_REPOS = [
'anthropics/skills',
'sickn33/antigravity-awesome-skills',
'K-Dense-AI/claude-scientific-skills',
'travisvn/awesome-claude-skills',
'VoltAgent/awesome-agent-skills',
'anthropics/knowledge-work-plugins',
'alirezarezvani/claude-skills',
]
const MAX_SKILLS = 300
const CONCURRENCY = 10
const MAX_RATE_LIMIT_WAIT_SECS = 60
// Skill scan bases matching installer.rs SKILL_SCAN_BASES
const SKILL_SCAN_BASES = [
'skills',
'skills/.curated',
'skills/.experimental',
'skills/.system',
'.claude/skills',
]
// Directories to skip when scanning root-level subdirs
const ROOT_SKIP_DIRS = new Set([
'skills', '.claude', '.git', '.github', '.vscode', 'node_modules',
'.idea', '.DS_Store', 'dist', 'build', 'out', 'target',
'docs', 'test', 'tests', '__tests__', 'examples', 'src', 'lib',
])
// Category classification rules
const CATEGORY_RULES = [
{ keywords: ['browser', 'automation', 'playwright', 'puppeteer'], category: 'browser-automation' },
{ keywords: ['security', 'audit', 'vulnerability', 'pentest'], category: 'security' },
{ keywords: ['devops', 'deploy', 'infra', 'docker', 'kubernetes'], category: 'devops' },
{ keywords: ['marketing', 'seo', 'ads', 'advertising'], category: 'marketing' },
{ keywords: ['database', 'sql', 'postgres', 'mongo'], category: 'database' },
{ keywords: ['git', 'github', 'pr', 'code-review'], category: 'development' },
{ keywords: ['ai', 'llm', 'agent', 'model'], category: 'ai-assistant' },
]
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''
// ─── HTTP helpers ───
async function fetchJson(url, retries = 3) {
const headers = {
Accept: 'application/vnd.github+json',
'User-Agent': 'skills-hub-aggregator',
}
if (GITHUB_TOKEN) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
for (let attempt = 0; attempt <= retries; attempt++) {
const res = await fetch(url, {
headers: { 'User-Agent': 'skills-hub-ci' },
})
if (res.status === 429 && attempt < retries) {
const delay = 2 ** (attempt + 1) * 1000 // 2s, 4s, 8s
console.warn(`Rate limited (429), retrying in ${delay / 1000}s... (${attempt + 1}/${retries})`)
await sleep(delay)
const res = await fetch(url, { headers })
if (res.status === 403 || res.status === 429) {
const resetHeader = res.headers.get('x-ratelimit-reset')
let waitSecs = resetHeader
? Math.max(Number(resetHeader) - Math.floor(Date.now() / 1000), 1)
: Math.pow(2, attempt + 1)
if (waitSecs > MAX_RATE_LIMIT_WAIT_SECS) {
console.warn(`Rate limited, reset in ${waitSecs}s (exceeds max ${MAX_RATE_LIMIT_WAIT_SECS}s) — skipping`)
return null
}
console.warn(`Rate limited (${res.status}), waiting ${waitSecs}s (attempt ${attempt + 1}/${retries + 1})...`)
await sleep(waitSecs * 1000)
continue
}
if (!res.ok) throw new Error(`${url} returned ${res.status}`)
if (!res.ok) {
if (attempt < retries) {
await sleep(Math.pow(2, attempt) * 1000)
continue
}
return null
}
return res.json()
}
return null
}
async function getOwnerHandle(slug) {
try {
const data = await fetchJson(`${CLAWHUB_DETAIL_API}/${encodeURIComponent(slug)}`)
return data?.owner?.handle ?? ''
} catch {
return ''
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
// Run async tasks with bounded concurrency
async function pMap(items, fn, concurrency) {
const results = new Array(items.length)
let idx = 0
async function worker() {
while (idx < items.length) {
const i = idx++
results[i] = await fn(items[i], i)
}
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
await Promise.all(workers)
return results
}
// ─── Step 1: Fetch curated repo metadata ───
async function fetchRepoMetadata(fullName) {
const url = `https://api.github.com/repos/${fullName}`
const data = await fetchJson(url)
if (!data || !data.full_name) {
console.warn(` Skipping ${fullName}: unable to fetch metadata`)
return null
}
return data
}
async function fetchAllRepoMetadata() {
console.log(`Fetching metadata for ${CURATED_REPOS.length} curated repos...`)
const results = await pMap(
CURATED_REPOS,
async (fullName) => {
console.log(` Fetching: ${fullName}`)
return fetchRepoMetadata(fullName)
},
CONCURRENCY,
)
const repos = results.filter(Boolean)
console.log(`Successfully fetched ${repos.length}/${CURATED_REPOS.length} repos`)
return repos
}
// ─── Step 2: Detect skills in each repo ───
function detectSkillsFromTree(treeItems) {
const filePaths = new Set()
const dirPaths = new Set()
for (const item of treeItems) {
if (item.type === 'blob') filePaths.add(item.path)
else if (item.type === 'tree') dirPaths.add(item.path)
}
const skills = [] // { dirPath }
const foundDirs = new Set()
// Scan SKILL_SCAN_BASES
for (const base of SKILL_SCAN_BASES) {
for (const dir of dirPaths) {
if (!dir.startsWith(base + '/')) continue
const rest = dir.slice(base.length + 1)
if (rest.includes('/')) continue // not a direct child
const hasSkillMd = filePaths.has(dir + '/SKILL.md')
const isClaudeSkill = base === '.claude/skills'
if (hasSkillMd || isClaudeSkill) {
if (!foundDirs.has(dir)) {
foundDirs.add(dir)
skills.push({ dirPath: dir })
}
}
}
}
// Scan root-level subdirectories (must have SKILL.md or .claude-plugin/plugin.json)
for (const dir of dirPaths) {
if (dir.includes('/')) continue
if (ROOT_SKIP_DIRS.has(dir) || dir.startsWith('.')) continue
if (foundDirs.has(dir)) continue
if (filePaths.has(dir + '/SKILL.md') || filePaths.has(dir + '/.claude-plugin/plugin.json')) {
foundDirs.add(dir)
skills.push({ dirPath: dir })
}
}
// Single-skill repo: root has SKILL.md and no sub-skills found
if (skills.length === 0 && filePaths.has('SKILL.md')) {
skills.push({ dirPath: null })
}
return skills
}
async function getRepoTree(owner, repo, branch) {
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`
const data = await fetchJson(url, 2)
if (!data || !data.tree) return null
if (data.truncated) {
console.warn(` Warning: tree for ${owner}/${repo} was truncated, some skills may be missed`)
}
return data.tree
}
// ─── Step 3: Classify ───
function classify(topics, description) {
const text = [...(topics || []), description || ''].join(' ').toLowerCase()
for (const rule of CATEGORY_RULES) {
if (rule.keywords.some((kw) => text.includes(kw))) {
return rule.category
}
}
return 'general'
}
// ─── SKILL.md helpers ───
function parseSkillMdFrontmatter(content) {
const lines = content.split('\n')
if (lines[0].trim() !== '---') return null
let name = null
let description = null
for (let i = 1; i < lines.length; i++) {
const l = lines[i].trim()
if (l === '---') break
if (l.startsWith('name:')) {
name = l.slice(5).trim().replace(/^["']|["']$/g, '')
} else if (l.startsWith('description:')) {
description = l.slice(12).trim().replace(/^["']|["']$/g, '')
}
}
return { name, description }
}
async function fetchSkillMdContent(owner, repo, branch, dirPath) {
const filePath = dirPath ? `${dirPath}/SKILL.md` : 'SKILL.md'
const url = `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}?ref=${encodeURIComponent(branch)}`
const data = await fetchJson(url, 1)
if (!data || !data.content) return null
const content = Buffer.from(data.content, 'base64').toString('utf-8')
return parseSkillMdFrontmatter(content)
}
// ─── Name helpers ───
function kebabToTitle(name) {
return name
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
function slugFromDirPath(dirPath, repoName) {
if (!dirPath) return repoName
const parts = dirPath.split('/')
return parts[parts.length - 1]
}
// ─── Main ───
async function main() {
console.log('Fetching skills from ClawHub API...')
let clawSkills
try {
const data = await fetchJson(CLAWHUB_LIST_API)
clawSkills = Array.isArray(data) ? data : data.items ?? data.skills ?? data.data ?? []
} catch (err) {
console.error('Failed to fetch ClawHub API:', err.message)
if (!GITHUB_TOKEN) {
console.error('Error: GITHUB_TOKEN environment variable is required.')
console.error('Set it via: export GITHUB_TOKEN=ghp_xxx')
process.exit(1)
}
console.log(`Got ${clawSkills.length} skills from ClawHub`)
console.log('Resolving skill owners...')
const skills = []
// Process in batches of 10 for reasonable parallelism
for (let i = 0; i < clawSkills.length; i += 10) {
const batch = clawSkills.slice(i, i + 10)
const results = await Promise.all(
batch.map(async (s) => {
const slug = s.slug ?? ''
if (!slug) return null
const owner = await getOwnerHandle(slug)
const source_url = owner
? `https://github.com/${GITHUB_REPO}/tree/main/skills/${owner}/${slug}`
: ''
const stats = s.stats ?? {}
return {
slug,
name: s.displayName ?? s.name ?? slug,
summary: s.summary ?? s.description ?? '',
downloads: stats.downloads ?? s.downloads ?? 0,
stars: stats.stars ?? s.stars ?? 0,
source_url,
}
}),
)
skills.push(...results.filter(Boolean))
process.stdout.write(` ${Math.min(i + 10, clawSkills.length)}/${clawSkills.length}\r`)
// Step 1: Fetch curated repo metadata
const repos = await fetchAllRepoMetadata()
// Step 2: Detect skills in each repo via Trees API
console.log('Scanning repo trees for skills...')
const skillEntries = [] // { repo, dirPath }
let treeFailures = 0
await pMap(
repos,
async (repo) => {
const [owner, repoName] = repo.full_name.split('/')
const tree = await getRepoTree(owner, repoName, repo.default_branch)
if (!tree) {
treeFailures++
return
}
const detected = detectSkillsFromTree(tree)
if (detected.length === 0) {
// No detectable skill structure — treat whole repo as single skill
skillEntries.push({ repo, dirPath: null })
return
}
for (const s of detected) {
skillEntries.push({ repo, dirPath: s.dirPath })
}
},
CONCURRENCY,
)
console.log(`Detected ${skillEntries.length} skills across ${repos.length} repos (${treeFailures} tree fetch failures)`)
// Fallback: if no skills detected, keep existing local file
if (skillEntries.length === 0) {
if (existsSync(OUTPUT_FILE)) {
console.warn('No skills fetched from GitHub — keeping existing local featured-skills.json')
} else {
console.error('No skills fetched and no local fallback exists.')
process.exit(1)
}
return
}
console.log('')
const matched = skills.filter((s) => s.source_url).length
console.log(`Matched ${matched}/${skills.length} skills to GitHub paths`)
// Step 3: Sort by stars desc, deduplicate, take top MAX_SKILLS
skillEntries.sort((a, b) => b.repo.stargazers_count - a.repo.stargazers_count)
const seenSlugs = new Set()
const dedupedEntries = skillEntries.filter((entry) => {
const slug = slugFromDirPath(entry.dirPath, entry.repo.full_name.split('/')[1])
if (seenSlugs.has(slug)) return false
seenSlugs.add(slug)
return true
})
console.log(`After dedup: ${dedupedEntries.length} unique skills (removed ${skillEntries.length - dedupedEntries.length} duplicates)`)
const topEntries = dedupedEntries.slice(0, MAX_SKILLS)
// Step 4: Fetch SKILL.md for top skills to get real names
console.log(`Fetching SKILL.md for ${topEntries.length} skills...`)
const skillMdMap = new Map() // index -> { name, description }
await pMap(
topEntries,
async (entry, i) => {
const [owner, repoName] = entry.repo.full_name.split('/')
const md = await fetchSkillMdContent(owner, repoName, entry.repo.default_branch, entry.dirPath)
if (md) skillMdMap.set(i, md)
},
CONCURRENCY,
)
console.log(`Fetched SKILL.md for ${skillMdMap.size}/${topEntries.length} skills`)
// Filter out entries without SKILL.md
const validEntries = topEntries.filter((_, i) => skillMdMap.has(i))
console.log(`${validEntries.length} skills have SKILL.md (filtered out ${topEntries.length - validEntries.length})`)
// Rebuild index mapping after filtering
const validMdList = validEntries.map((entry, _i) => {
const origIndex = topEntries.indexOf(entry)
return { entry, md: skillMdMap.get(origIndex) }
})
// Step 5: Build output
const categorySet = new Set()
const topSkills = validMdList.map(({ entry, md }) => {
const { repo, dirPath } = entry
const repoName = repo.full_name.split('/')[1]
const slug = slugFromDirPath(dirPath, repoName)
const name = md.name || slug
const summary = (md && md.description) || repo.description || ''
const category = classify(repo.topics, repo.description)
categorySet.add(category)
let sourceUrl
if (dirPath) {
sourceUrl = `${repo.html_url}/tree/${repo.default_branch}/${dirPath}`
} else {
sourceUrl = repo.html_url
}
return {
slug,
name,
summary,
downloads: 0,
stars: repo.stargazers_count,
category,
tags: (repo.topics || []).slice(0, 5),
source_url: sourceUrl,
updated_at: repo.updated_at,
}
})
// Re-sort after name update (stars desc, then name asc)
topSkills.sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name))
const categories = Array.from(categorySet).sort()
const output = {
updated_at: new Date().toISOString(),
skills,
total: topSkills.length,
categories,
skills: topSkills,
}
const { writeFileSync } = await import('node:fs')
writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2) + '\n')
console.log(`Wrote ${skills.length} skills to ${OUTPUT_FILE}`)
console.log(`Wrote ${topSkills.length} skills (of ${skillEntries.length} detected) to ${OUTPUT_FILE}`)
}
main()
main().catch((err) => {
console.error('Fatal error:', err)
process.exit(1)
})

View File

@ -48,14 +48,18 @@ pub fn fetch_featured_skills(store: &SkillStore) -> Result<Vec<FeaturedSkill>> {
fn fetch_featured_skills_inner(url: &str, store: &SkillStore) -> Result<Vec<FeaturedSkill>> {
if let Ok(json_str) = fetch_from_url(url) {
if let Ok(skills) = parse_and_filter(&json_str) {
let _ = store.set_setting(CACHE_KEY, &json_str);
return Ok(skills);
if !skills.is_empty() {
let _ = store.set_setting(CACHE_KEY, &json_str);
return Ok(skills);
}
}
}
// Fallback to cache
if let Ok(Some(cached)) = store.get_setting(CACHE_KEY) {
if let Ok(skills) = parse_and_filter(&cached) {
return Ok(skills);
if !skills.is_empty() {
return Ok(skills);
}
}
}
// Fallback to bundled JSON