mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-07 08:40:16 +08:00
chore: release v0.11.5
feat: SkillHub skill store (SDK-based, no CLI dependency)
- Rust SDK (skillhub.rs): HTTP search, index fetch, zip download+extract
- Node.js SDK (skillhub-sdk.js): mirrors Rust SDK for Web/Docker mode
- Skills page: new "Store" tab with full index browse + client-side filter
- Remove 6 old CLI-dependent commands, add 3 SDK commands
- Migrate assistant.js skill tools from ClawHub CLI to SkillHub SDK
- Fix index decode error ({total,skills} wrapper vs bare array)
- Fix skill name display (API field 'name' vs 'display_name')
- Clean up 13 dead CSS rules from old skills hero/tips UI
This commit is contained in:
216
scripts/lib/skillhub-sdk.js
Normal file
216
scripts/lib/skillhub-sdk.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* SkillHub SDK — Node.js 版
|
||||
* 纯 HTTP + zip 操作,API 接口与 Rust SDK (skillhub.rs) 完全对齐。
|
||||
* 供 dev-api.js Web/Docker 端调用。
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { inflateRaw } from 'zlib'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const inflateRawAsync = promisify(inflateRaw)
|
||||
|
||||
const COS_BASE = 'https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com'
|
||||
const API_BASE = 'https://lightmake.site/api/v1'
|
||||
const INDEX_TTL = 10 * 60 * 1000 // 10 分钟缓存
|
||||
|
||||
let _indexCache = null // { ts: number, items: Array }
|
||||
|
||||
// ── 公开接口 ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 搜索 SkillHub
|
||||
* @param {string} query
|
||||
* @param {number} [limit=20]
|
||||
* @returns {Promise<Array<{slug, displayName, summary, version}>>}
|
||||
*/
|
||||
export async function search(query, limit = 20) {
|
||||
const q = (query || '').trim()
|
||||
if (!q) return []
|
||||
const url = `${API_BASE}/search?q=${encodeURIComponent(q)}&limit=${limit}`
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
if (!resp.ok) throw new Error(`SkillHub 搜索失败: HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
return data.results || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取全量索引(带 10 分钟内存缓存)
|
||||
* @returns {Promise<Array<{slug, displayName, summary, version}>>}
|
||||
*/
|
||||
export async function fetchIndex() {
|
||||
if (_indexCache && Date.now() - _indexCache.ts < INDEX_TTL) {
|
||||
return _indexCache.items
|
||||
}
|
||||
const url = `${COS_BASE}/skills.json`
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
if (!resp.ok) throw new Error(`拉取技能索引失败: HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = data.skills || data // 兼容 {total, skills} 包装和裸数组
|
||||
_indexCache = { ts: Date.now(), items }
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 Skill zip(COS 镜像优先,回退主站 API)
|
||||
* @param {string} slug
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
export async function downloadZip(slug) {
|
||||
// 1. 优先 COS 镜像(国内 CDN)
|
||||
try {
|
||||
const resp = await fetch(`${COS_BASE}/skills/${slug}.zip`, {
|
||||
signal: AbortSignal.timeout(30000)
|
||||
})
|
||||
if (resp.ok) return Buffer.from(await resp.arrayBuffer())
|
||||
} catch { /* COS 失败,回退主站 */ }
|
||||
// 2. 回退主站 API
|
||||
const resp = await fetch(`${API_BASE}/download?slug=${encodeURIComponent(slug)}`, {
|
||||
signal: AbortSignal.timeout(30000)
|
||||
})
|
||||
if (!resp.ok) throw new Error(`下载失败: HTTP ${resp.status}`)
|
||||
return Buffer.from(await resp.arrayBuffer())
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并安装 Skill:zip → 解压到 skillsDir/{slug}/
|
||||
* @param {string} slug
|
||||
* @param {string} skillsDir - 如 ~/.openclaw/skills/
|
||||
* @returns {Promise<string>} 安装路径
|
||||
*/
|
||||
export async function install(slug, skillsDir) {
|
||||
validateSlug(slug)
|
||||
const targetDir = path.join(skillsDir, slug)
|
||||
const zipBuf = await downloadZip(slug)
|
||||
await extractZip(zipBuf, targetDir)
|
||||
return targetDir
|
||||
}
|
||||
|
||||
// ── 内部工具 ──────────────────────────────────────────────
|
||||
|
||||
function validateSlug(slug) {
|
||||
if (!slug) throw new Error('Skill slug 不能为空')
|
||||
if (slug.includes('..') || slug.includes('/') || slug.includes('\\')) {
|
||||
throw new Error(`无效的 Skill slug: ${slug}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯 Node.js zip 解压(无外部依赖)
|
||||
* 支持 Deflate (method 8) 和 Stored (method 0)
|
||||
* @param {Buffer} zipBuf
|
||||
* @param {string} targetDir
|
||||
*/
|
||||
async function extractZip(zipBuf, targetDir) {
|
||||
// 清理旧目录
|
||||
if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
|
||||
const entries = parseZipEntries(zipBuf)
|
||||
if (!entries.length) throw new Error('zip 文件为空或无法解析')
|
||||
|
||||
// 检测单一根目录(常见打包方式),需要剥掉
|
||||
const stripPrefix = detectSingleRootDir(entries)
|
||||
|
||||
for (const entry of entries) {
|
||||
let name = entry.name
|
||||
// 安全检查
|
||||
if (name.includes('..')) continue
|
||||
|
||||
// 剥掉单一根目录
|
||||
if (stripPrefix) {
|
||||
if (!name.startsWith(stripPrefix)) continue
|
||||
name = name.slice(stripPrefix.length)
|
||||
if (!name) continue
|
||||
}
|
||||
|
||||
const outPath = path.join(targetDir, name)
|
||||
|
||||
if (entry.isDir) {
|
||||
fs.mkdirSync(outPath, { recursive: true })
|
||||
} else {
|
||||
const dir = path.dirname(outPath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
let data
|
||||
if (entry.method === 0) {
|
||||
// Stored
|
||||
data = zipBuf.subarray(entry.dataOffset, entry.dataOffset + entry.compressedSize)
|
||||
} else if (entry.method === 8) {
|
||||
// Deflate
|
||||
const compressed = zipBuf.subarray(entry.dataOffset, entry.dataOffset + entry.compressedSize)
|
||||
data = await inflateRawAsync(compressed)
|
||||
} else {
|
||||
console.warn(`[skillhub-sdk] 跳过不支持的压缩方法 ${entry.method}: ${name}`)
|
||||
continue
|
||||
}
|
||||
fs.writeFileSync(outPath, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 zip 文件的 Local File Header 条目
|
||||
* @param {Buffer} buf
|
||||
* @returns {Array<{name, isDir, method, compressedSize, dataOffset}>}
|
||||
*/
|
||||
function parseZipEntries(buf) {
|
||||
const entries = []
|
||||
let offset = 0
|
||||
const LOCAL_FILE_HEADER_SIG = 0x04034b50
|
||||
|
||||
while (offset + 30 <= buf.length) {
|
||||
const sig = buf.readUInt32LE(offset)
|
||||
if (sig !== LOCAL_FILE_HEADER_SIG) break
|
||||
|
||||
const method = buf.readUInt16LE(offset + 8)
|
||||
const compressedSize = buf.readUInt32LE(offset + 18)
|
||||
const uncompressedSize = buf.readUInt32LE(offset + 22)
|
||||
const nameLen = buf.readUInt16LE(offset + 26)
|
||||
const extraLen = buf.readUInt16LE(offset + 28)
|
||||
const name = buf.subarray(offset + 30, offset + 30 + nameLen).toString('utf8')
|
||||
const dataOffset = offset + 30 + nameLen + extraLen
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
isDir: name.endsWith('/'),
|
||||
method,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
dataOffset,
|
||||
})
|
||||
|
||||
// 处理 data descriptor (bit 3 of general purpose bit flag)
|
||||
const gpFlag = buf.readUInt16LE(offset + 6)
|
||||
let dataSize = compressedSize
|
||||
if ((gpFlag & 0x08) && compressedSize === 0) {
|
||||
// Data descriptor 跟在压缩数据后面,需要查找
|
||||
// 简化处理:跳过这种情况(极少见)
|
||||
break
|
||||
}
|
||||
|
||||
offset = dataOffset + dataSize
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 zip 是否有单一顶层目录
|
||||
* @param {Array<{name}>} entries
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function detectSingleRootDir(entries) {
|
||||
let root = null
|
||||
for (const entry of entries) {
|
||||
const firstSeg = entry.name.split('/')[0]
|
||||
if (!firstSeg) continue
|
||||
const prefix = firstSeg + '/'
|
||||
if (root === null) {
|
||||
root = prefix
|
||||
} else if (!entry.name.startsWith(root)) {
|
||||
return null // 多个顶层目录
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
Reference in New Issue
Block a user