mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +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:
@@ -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
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