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:
晴天
2026-04-07 03:25:26 +08:00
parent b57235e2a7
commit ad00ffef3d
20 changed files with 1244 additions and 851 deletions

View File

@@ -12,6 +12,7 @@ import { fileURLToPath } from 'url'
import net from 'net'
import http from 'http'
import crypto from 'crypto'
import * as skillhubSdk from './lib/skillhub-sdk.js'
const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000
const __dev_dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -5348,38 +5349,23 @@ const handlers = {
return true
},
// Skills 管理(模拟 openclaw skills CLI JSON 输出
// Skills 管理(纯本地扫描,不依赖 CLI
skills_list() {
try {
const out = execOpenclawSync(['skills', 'list', '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '读取 Skills 列表失败')
return extractCliJson(out)
} catch (e) {
return scanLocalSkillsFallback(e)
}
return scanLocalSkillsFallback()
},
skills_info({ name }) {
try {
const out = execOpenclawSync(['skills', 'info', String(name || '').trim(), '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '查看 Skill 详情失败')
return extractCliJson(out)
} catch (e) {
const fallback = scanLocalSkillsFallback(e).skills.find(skill => skill.name === String(name || '').trim())
if (fallback) return fallback
throw new Error('查看详情失败: ' + (e.message || e))
}
const n = String(name || '').trim()
const fallback = scanLocalSkillsFallback().skills.find(skill => skill.name === n)
if (fallback) return fallback
throw new Error(`Skill「${n}」不存在`)
},
skills_check() {
try {
const out = execOpenclawSync(['skills', 'check', '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '检查 Skills 依赖失败')
return extractCliJson(out)
} catch (e) {
const fallback = scanLocalSkillsFallback(e)
return {
summary: fallback.summary,
eligible: fallback.eligible,
disabled: fallback.disabled,
blocked: fallback.blocked,
missingRequirements: fallback.missingRequirements,
}
const data = scanLocalSkillsFallback()
return {
total: data.skills.length,
ready: (data.eligible || []).length,
missingDeps: (data.missingRequirements || []).length,
skills: data.skills,
}
},
skills_install_dep({ kind, spec }) {
@@ -5398,69 +5384,6 @@ const handlers = {
throw new Error(`安装失败: ${e.message || e}`)
}
},
skills_skillhub_check() {
try {
const out = execSync('skillhub --cli-version', { encoding: 'utf8', timeout: 5000 })
return { installed: true, version: out.trim() }
} catch {
return { installed: false }
}
},
skills_skillhub_setup({ cliOnly }) {
const flag = cliOnly ? '--cli-only' : '--no-skills'
try {
const out = execSync(
`curl -fsSL https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/install/install.sh | bash -s -- ${flag}`,
{ encoding: 'utf8', timeout: 120000 }
)
return { success: true, output: out.trim() }
} catch (e) {
throw new Error('SkillHub 安装失败: ' + (e.message || e))
}
},
skills_skillhub_search({ query }) {
const q = String(query || '').trim()
if (!q) return []
try {
const out = execSync(`skillhub search ${JSON.stringify(q)}`, { encoding: 'utf8', timeout: 30000 })
// 解析格式: [N] owner/repo/name 状态\n 统计 描述...
const lines = out.split('\n')
const items = []
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
if (!trimmed.startsWith('[')) continue
const bracketEnd = trimmed.indexOf(']')
if (bracketEnd < 0) continue
const afterBracket = trimmed.slice(bracketEnd + 1).trim()
const slug = (afterBracket.split(/\s/)[0] || '').trim()
if (!slug.includes('/')) continue
let desc = ''
if (i + 1 < lines.length) {
const next = lines[i + 1].trim()
const starIdx = next.indexOf('⭐')
if (starIdx >= 0) {
const afterStar = next.slice(starIdx + 2).trim()
desc = afterStar.replace(/^[\d.]+[kKmM]?\s*/, '').trim()
}
}
items.push({ slug, description: desc, source: 'skillhub' })
}
return items
} catch (e) {
throw new Error('搜索失败: ' + (e.message || e) + '。请先安装 SkillHub CLI')
}
},
skills_skillhub_install({ slug }) {
const skillsDir = path.join(OPENCLAW_DIR, 'skills')
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
try {
const out = execSync(`skillhub install ${JSON.stringify(slug)} --force`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 })
return { success: true, slug, output: out.trim() }
} catch (e) {
throw new Error('安装失败: ' + (e.message || e) + '。请先安装 SkillHub CLI')
}
},
skills_uninstall({ name }) {
if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) throw new Error('无效的 Skill 名称')
const skillDir = path.join(OPENCLAW_DIR, 'skills', name)
@@ -5468,32 +5391,18 @@ const handlers = {
fs.rmSync(skillDir, { recursive: true, force: true })
return { success: true, name }
},
skills_clawhub_search({ query }) {
const q = String(query || '').trim()
if (!q) return []
try {
const out = execSync(`npx -y clawhub search ${JSON.stringify(q)}`, { encoding: 'utf8', timeout: 30000 })
return out.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('-') && !line.startsWith('Search'))
.map(line => {
const parts = line.split(/\s{2,}/).filter(Boolean)
return { slug: parts[0] || '', description: parts.slice(1).join(' ').trim(), source: 'clawhub' }
})
.filter(item => item.slug)
} catch (e) {
throw new Error('搜索失败: ' + (e.message || e))
}
// SkillHub SDK内置 HTTP不依赖 CLI
async skillhub_search({ query, limit }) {
return await skillhubSdk.search(query, limit || 20)
},
skills_clawhub_install({ slug }) {
async skillhub_index() {
return await skillhubSdk.fetchIndex()
},
async skillhub_install({ slug }) {
const skillsDir = path.join(OPENCLAW_DIR, 'skills')
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
try {
const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)}`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 })
return { success: true, slug, output: out.trim() }
} catch (e) {
throw new Error('安装失败: ' + (e.message || e))
}
const installedPath = await skillhubSdk.install(slug, skillsDir)
return { success: true, slug, path: installedPath }
},
// 设备配对 + Gateway 握手

216
scripts/lib/skillhub-sdk.js Normal file
View 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 zipCOS 镜像优先,回退主站 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())
}
/**
* 下载并安装 Skillzip → 解压到 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
}