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

2
src-tauri/Cargo.lock generated
View File

@@ -351,7 +351,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.11.4"
version = "0.11.5"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.11.4"
version = "0.11.5"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

@@ -23,6 +23,7 @@ pub mod memory;
pub mod messaging;
pub mod pairing;
pub mod service;
pub mod skillhub;
pub mod skills;
pub mod update;

View File

@@ -0,0 +1,260 @@
//! SkillHub SDK — 纯 HTTP + zip 操作,不依赖 Tauri 框架。
//! 供 skills.rs Tauri 命令层薄包装调用。
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const COS_BASE: &str = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com";
const API_BASE: &str = "https://lightmake.site/api/v1";
const INDEX_TTL: Duration = Duration::from_secs(600); // 10 分钟缓存
// ── 数据结构 ──────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillHubItem {
pub slug: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub categories: Option<Vec<String>>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub downloads: Option<u64>,
#[serde(default)]
pub stars: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
results: Vec<SkillHubItem>,
}
#[derive(Debug, Deserialize)]
struct IndexResponse {
#[serde(default)]
skills: Vec<SkillHubItem>,
}
// ── 全量索引缓存 ──────────────────────────────────────────
static INDEX_CACHE: Mutex<Option<(Instant, Vec<SkillHubItem>)>> = Mutex::new(None);
// ── HTTP 客户端 ──────────────────────────────────────────
fn client() -> Result<reqwest::Client, String> {
super::build_http_client(Duration::from_secs(30), Some("ClawPanel-SkillHub/1.0"))
}
// ── 公开接口 ──────────────────────────────────────────────
/// 搜索 SkillHub
pub async fn search(query: &str, limit: u32) -> Result<Vec<SkillHubItem>, String> {
let q = query.trim();
if q.is_empty() {
return Ok(vec![]);
}
let url = format!(
"{}/search?q={}&limit={}",
API_BASE,
urlencoding::encode(q),
limit
);
let resp = client()?
.get(&url)
.send()
.await
.map_err(|e| format!("SkillHub 搜索请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("SkillHub 搜索失败: HTTP {}", resp.status()));
}
let data: SearchResponse = resp
.json()
.await
.map_err(|e| format!("SkillHub 搜索结果解析失败: {e}"))?;
Ok(data.results)
}
/// 拉取全量索引(带 10 分钟内存缓存)
pub async fn fetch_index() -> Result<Vec<SkillHubItem>, String> {
// 命中缓存
if let Ok(guard) = INDEX_CACHE.lock() {
if let Some((ts, ref items)) = *guard {
if ts.elapsed() < INDEX_TTL {
return Ok(items.clone());
}
}
}
// 拉取远程索引
let url = format!("{}/skills.json", COS_BASE);
let resp = client()?
.get(&url)
.send()
.await
.map_err(|e| format!("拉取技能索引失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("拉取技能索引失败: HTTP {}", resp.status()));
}
let data: IndexResponse = resp
.json()
.await
.map_err(|e| format!("解析技能索引失败: {e}"))?;
let items = data.skills;
// 写入缓存
if let Ok(mut guard) = INDEX_CACHE.lock() {
*guard = Some((Instant::now(), items.clone()));
}
Ok(items)
}
/// 下载 Skill zipCOS 镜像优先,回退主站 API
pub async fn download_zip(slug: &str) -> Result<Vec<u8>, String> {
let c = client()?;
// 1. 优先 COS 镜像(国内 CDN
let cos_url = format!("{}/skills/{}.zip", COS_BASE, slug);
match c.get(&cos_url).send().await {
Ok(resp) if resp.status().is_success() => {
return resp
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("COS 下载读取失败: {e}"));
}
_ => {}
}
// 2. 回退主站 API
let api_url = format!("{}/download?slug={}", API_BASE, urlencoding::encode(slug));
let resp = c
.get(&api_url)
.send()
.await
.map_err(|e| format!("主站下载请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("下载失败: HTTP {}", resp.status()));
}
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("下载读取失败: {e}"))
}
/// 下载并安装 Skillzip → 解压到 skills_dir/{slug}/
pub async fn install(slug: &str, skills_dir: &Path) -> Result<PathBuf, String> {
validate_slug(slug)?;
let target_dir = skills_dir.join(slug);
let zip_bytes = download_zip(slug).await?;
extract_zip(&zip_bytes, &target_dir)?;
Ok(target_dir)
}
// ── 内部工具 ──────────────────────────────────────────────
/// 校验 slug 安全性
fn validate_slug(slug: &str) -> Result<(), String> {
if slug.is_empty() {
return Err("Skill slug 不能为空".into());
}
if slug.contains("..") || slug.contains('/') || slug.contains('\\') {
return Err(format!("无效的 Skill slug: {slug}"));
}
Ok(())
}
/// 将 zip 字节解压到目标目录
fn extract_zip(zip_bytes: &[u8], target_dir: &Path) -> Result<(), String> {
use std::io::Cursor;
use zip::ZipArchive;
// 清理旧目录
if target_dir.exists() {
std::fs::remove_dir_all(target_dir)
.map_err(|e| format!("清理旧目录失败: {e}"))?;
}
std::fs::create_dir_all(target_dir)
.map_err(|e| format!("创建目录失败: {e}"))?;
let reader = Cursor::new(zip_bytes);
let mut archive =
ZipArchive::new(reader).map_err(|e| format!("打开 zip 失败: {e}"))?;
// 收集所有文件名,检测是否都在同一个顶层目录下(常见的 zip 打包方式)
let names: Vec<String> = (0..archive.len())
.filter_map(|i| archive.by_index_raw(i).ok().map(|f| f.name().to_string()))
.collect();
let strip_prefix = detect_single_root_dir(&names);
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("读取 zip 条目失败: {e}"))?;
let raw_name = file.name().to_string();
// 安全检查:防止路径穿越
if raw_name.contains("..") {
continue;
}
// 如果 zip 内有单一根目录,剥掉它
let relative = if let Some(ref prefix) = strip_prefix {
match raw_name.strip_prefix(prefix.as_str()) {
Some(rest) if !rest.is_empty() => rest.to_string(),
_ => continue, // 跳过根目录本身
}
} else {
raw_name.clone()
};
if relative.is_empty() {
continue;
}
let out_path = target_dir.join(&relative);
if file.is_dir() {
std::fs::create_dir_all(&out_path).ok();
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let mut outfile = std::fs::File::create(&out_path)
.map_err(|e| format!("创建文件失败 {relative}: {e}"))?;
std::io::copy(&mut file, &mut outfile)
.map_err(|e| format!("写入文件失败 {relative}: {e}"))?;
}
}
Ok(())
}
/// 检测 zip 是否有单一顶层目录(如 `skill-name/...`),返回要剥掉的前缀
fn detect_single_root_dir(names: &[String]) -> Option<String> {
let mut root: Option<String> = None;
for name in names {
let first_segment = name.split('/').next().unwrap_or("");
if first_segment.is_empty() {
continue;
}
match &root {
None => root = Some(format!("{}/", first_segment)),
Some(existing) => {
if !name.starts_with(existing.as_str()) {
return None; // 多个顶层目录
}
}
}
}
root
}

View File

@@ -1,118 +1,35 @@
use crate::utils::openclaw_command_async;
use serde_json::Value;
use std::collections::HashSet;
#[cfg(target_os = "windows")]
#[allow(unused_imports)]
use std::os::windows::process::CommandExt;
/// 列出所有 Skills 及其状态(openclaw skills list --json
/// 列出所有 Skills 及其状态(纯本地扫描,不依赖 CLI
#[tauri::command]
pub async fn skills_list() -> Result<Value, String> {
let output = tokio::time::timeout(
std::time::Duration::from_secs(15),
openclaw_command_async()
.args(["skills", "list", "--json"])
.output(),
)
.await;
match output {
Ok(Ok(o)) => {
let stdout = String::from_utf8_lossy(&o.stdout);
// CLI 可能在有 skill 缺依赖时返回非零退出码,但 JSON 输出仍然有效
// 优先尝试解析 JSON无论退出码
match extract_json(&stdout) {
Some(mut v) => {
if let Some(obj) = v.as_object_mut() {
obj.insert("cliAvailable".into(), Value::Bool(true));
obj.insert(
"diagnostic".into(),
serde_json::json!({
"status": "ok",
"message": "已使用 OpenClaw CLI 结果",
"exitCode": o.status.code().unwrap_or(0),
}),
);
}
merge_local_skills(v)
}
None => {
let stderr = String::from_utf8_lossy(&o.stderr);
eprintln!(
"[skills] CLI JSON 解析失败 (exit={})兜底扫描。stdout={} stderr={}",
o.status.code().unwrap_or(-1),
stdout.chars().take(200).collect::<String>(),
stderr.chars().take(200).collect::<String>()
);
scan_local_skills(Some(serde_json::json!({
"status": "parse-failed",
"message": "OpenClaw CLI 可执行,但返回结果未能解析为 JSON当前展示本地扫描结果",
"cliAvailable": true,
"exitCode": o.status.code().unwrap_or(-1),
"stderr": stderr.chars().take(200).collect::<String>(),
})))
}
}
}
Ok(Err(e)) => scan_local_skills(Some(serde_json::json!({
"status": "exec-failed",
"message": format!("调用 OpenClaw CLI 失败,当前展示本地扫描结果: {e}"),
"cliAvailable": false,
}))),
Err(_) => scan_local_skills(Some(serde_json::json!({
"status": "timeout",
"message": "OpenClaw CLI 调用超时,当前展示本地扫描结果",
"cliAvailable": true,
"timeoutSeconds": 15,
}))),
}
scan_local_skills(None)
}
/// 查看单个 Skill 详情(openclaw skills info <name> --json
/// 查看单个 Skill 详情(纯本地文件解析,不依赖 CLI
#[tauri::command]
pub async fn skills_info(name: String) -> Result<Value, String> {
let output = openclaw_command_async()
.args(["skills", "info", &name, "--json"])
.output()
.await
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
if !output.status.success() {
if let Some(local) = scan_custom_skill_detail(&name) {
return Ok(local);
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("获取详情失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed =
extract_json(&stdout).ok_or_else(|| "解析详情失败: 输出中未找到有效 JSON".to_string())?;
if parsed.get("error").and_then(|v| v.as_str()) == Some("not found") {
if let Some(local) = scan_custom_skill_detail(&name) {
return Ok(local);
}
}
Ok(parsed)
scan_custom_skill_detail(&name)
.ok_or_else(|| format!("Skill「{name}」不存在"))
}
/// 检查 Skills 依赖状态(openclaw skills check --json
/// 检查 Skills 依赖状态(纯本地扫描
#[tauri::command]
pub async fn skills_check() -> Result<Value, String> {
let output = openclaw_command_async()
.args(["skills", "check", "--json"])
.output()
.await
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("检查失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
extract_json(&stdout).ok_or_else(|| "解析失败: 输出中未找到有效 JSON".to_string())
let skills = scan_local_skill_entries()?;
let total = skills.len();
let ready = skills.iter().filter(|s| s.get("eligible").and_then(|v| v.as_bool()).unwrap_or(false)).count();
let missing = total - ready;
Ok(serde_json::json!({
"total": total,
"ready": ready,
"missingDeps": missing,
"skills": skills,
}))
}
/// 安装 Skill 依赖(根据 install spec 执行 brew/npm/go/uv/download
@@ -189,320 +106,35 @@ pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, Stri
}))
}
/// 检测 SkillHub CLI 是否已安装
/// 搜索 SkillHub(内置 HTTP不依赖 CLI
#[tauri::command]
pub async fn skills_skillhub_check() -> Result<Value, String> {
let path_env = super::enhanced_path();
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "skillhub", "--version"]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("skillhub");
c.arg("--version");
c
};
cmd.env("PATH", &path_env);
match cmd.output().await {
Ok(o) if o.status.success() => {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
Ok(serde_json::json!({ "installed": true, "version": ver }))
}
_ => Ok(serde_json::json!({ "installed": false })),
}
pub async fn skillhub_search(query: String, limit: Option<u32>) -> Result<Value, String> {
let items = super::skillhub::search(&query, limit.unwrap_or(20)).await?;
Ok(serde_json::to_value(items).unwrap_or_default())
}
/// 安装 SkillHub CLI从腾讯云 COS 下载
/// 获取全量技能索引COS CDN带内存缓存
#[tauri::command]
pub async fn skills_skillhub_setup(cli_only: bool) -> Result<Value, String> {
let path_env = super::enhanced_path();
#[allow(unused_variables)]
let flag = if cli_only {
"--cli-only"
} else {
"--no-skills"
};
#[cfg(not(target_os = "windows"))]
{
let mut cmd = tokio::process::Command::new("bash");
cmd.args(["-c", &format!(
"curl -fsSL https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/install/install.sh | bash -s -- {flag}"
)])
.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行安装脚本失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("SkillHub 安装失败: {}", stderr.trim()));
}
Ok(serde_json::json!({ "success": true, "output": stdout.trim() }))
}
#[cfg(target_os = "windows")]
{
// Windows: 通过 npm 全局安装 skillhub避免 bash/WSL 路径问题)
let mut cmd = tokio::process::Command::new("cmd");
cmd.args([
"/c",
"npm",
"install",
"-g",
"skillhub@latest",
"--registry",
"https://registry.npmmirror.com",
])
.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
cmd.creation_flags(0x08000000);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 npm install 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("SkillHub CLI 安装失败: {}", stderr.trim()));
}
Ok(serde_json::json!({ "success": true, "output": stdout.trim() }))
}
pub async fn skillhub_index() -> Result<Value, String> {
let items = super::skillhub::fetch_index().await?;
Ok(serde_json::to_value(items).unwrap_or_default())
}
/// 从 SkillHub 安装 Skillskillhub install <slug>
/// 从 SkillHub 安装 Skill内置 HTTP 下载 + zip 解压
#[tauri::command]
pub async fn skills_skillhub_install(slug: String) -> Result<Value, String> {
let path_env = super::enhanced_path();
let home = dirs::home_dir().unwrap_or_default();
pub async fn skillhub_install(slug: String) -> Result<Value, String> {
let skills_dir = super::openclaw_dir().join("skills");
if !skills_dir.exists() {
std::fs::create_dir_all(&skills_dir).map_err(|e| format!("创建 skills 目录失败: {e}"))?;
}
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "skillhub", "install", &slug, "--force"]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("skillhub");
c.args(["install", &slug, "--force"]);
c
};
cmd.env("PATH", &path_env).current_dir(&home);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 skillhub 失败: {e}。请先安装 SkillHub CLI"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("安装失败: {}", stderr.trim()));
}
let installed_path = super::skillhub::install(&slug, &skills_dir).await?;
Ok(serde_json::json!({
"success": true,
"slug": slug,
"output": stdout.trim(),
"path": installed_path.to_string_lossy(),
}))
}
/// 从 SkillHub 搜索 Skillsskillhub search <query>
#[tauri::command]
pub async fn skills_skillhub_search(query: String) -> Result<Value, String> {
let q = query.trim().to_string();
if q.is_empty() {
return Ok(Value::Array(vec![]));
}
let path_env = super::enhanced_path();
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "skillhub", "search", &q]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("skillhub");
c.args(["search", &q]);
c
};
cmd.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 skillhub 失败: {e}。请先安装 SkillHub CLI"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("搜索失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
// skillhub search 实际输出格式:
// ──────────────── (分隔线)
// [1] openclaw/openclaw/feishu-doc 🛡️ Pass
// AI 85 ⬇ 33 ⭐ 248.7k Feishu document read/write opera...
// ──────────────── (分隔线)
// 序号和 slug 在同一行,描述在下一行
let lines: Vec<&str> = stdout.lines().collect();
let mut items: Vec<Value> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
// 找序号行:以 [数字] 开头,同一行包含 slugowner/repo/name
if !trimmed.starts_with('[') {
continue;
}
let bracket_end = match trimmed.find(']') {
Some(pos) => pos,
None => continue,
};
// 提取 ] 后面的内容
let after_bracket = trimmed[bracket_end + 1..].trim();
// slug 是第一个空格前的部分,且包含 /
let slug = after_bracket.split_whitespace().next().unwrap_or("").trim();
if !slug.contains('/') {
continue;
}
// 描述在下一行:跳过数字、⬇、⭐ 等统计信息,提取文字描述
let mut desc = String::new();
if i + 1 < lines.len() {
let next = lines[i + 1].trim();
// 找到第一个英文或中文字母开始的描述文字
// 格式: "AI 85 ⬇ 33 ⭐ 248.7k Feishu document..."
// 或: "⬇ 0 ⭐ 212.2k Feishu document..."
// 策略:找 ⭐ 后面的数字后的文字
if let Some(star_pos) = next.find('⭐') {
let after_star = &next[star_pos + '⭐'.len_utf8()..].trim_start();
// 跳过星标数字(如 "248.7k"
let after_num = after_star
.trim_start_matches(|c: char| {
c.is_ascii_digit()
|| c == '.'
|| c == 'k'
|| c == 'K'
|| c == 'm'
|| c == 'M'
})
.trim();
if !after_num.is_empty() {
desc = after_num.to_string();
}
}
}
items.push(serde_json::json!({
"slug": slug,
"description": desc,
"source": "skillhub"
}));
}
Ok(Value::Array(items))
}
/// 从 ClawHub 搜索 Skillsnpx clawhub search <query>)— 原版海外源
#[tauri::command]
pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
let q = query.trim().to_string();
if q.is_empty() {
return Ok(Value::Array(vec![]));
}
let path_env = super::enhanced_path();
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "npx", "-y", "clawhub", "search", &q]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("npx");
c.args(["-y", "clawhub", "search", &q]);
c
};
cmd.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("搜索失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let items: Vec<Value> = stdout
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('-') && !l.starts_with("Search"))
.map(|l| {
let parts: Vec<&str> = l.splitn(2, char::is_whitespace).collect();
let slug = parts.first().unwrap_or(&"").trim();
let desc = parts.get(1).unwrap_or(&"").trim();
serde_json::json!({ "slug": slug, "description": desc, "source": "clawhub" })
})
.filter(|v| !v["slug"].as_str().unwrap_or("").is_empty())
.collect();
Ok(Value::Array(items))
}
/// 从 ClawHub 安装 Skillnpx clawhub install <slug>)— 原版海外源
#[tauri::command]
pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
let path_env = super::enhanced_path();
let home = dirs::home_dir().unwrap_or_default();
let skills_dir = super::openclaw_dir().join("skills");
if !skills_dir.exists() {
std::fs::create_dir_all(&skills_dir).map_err(|e| format!("创建 skills 目录失败: {e}"))?;
}
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "npx", "-y", "clawhub", "install", &slug]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("npx");
c.args(["-y", "clawhub", "install", &slug]);
c
};
cmd.env("PATH", &path_env).current_dir(&home);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("安装失败: {}", stderr.trim()));
}
Ok(serde_json::json!({ "success": true, "slug": slug, "output": stdout.trim() }))
}
/// 卸载 Skill删除 ~/.openclaw/skills/<name>/ 目录)
#[tauri::command]
pub async fn skills_uninstall(name: String) -> Result<Value, String> {
@@ -915,30 +547,6 @@ fn scan_custom_skill_detail(name: &str) -> Option<Value> {
None
}
fn merge_local_skills(mut data: Value) -> Result<Value, String> {
let local_skills = scan_local_skill_entries()?;
let Some(skills) = data.get_mut("skills").and_then(|v| v.as_array_mut()) else {
return Ok(data);
};
let mut existing = HashSet::new();
for item in skills.iter() {
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
existing.insert(name.to_string());
}
}
for skill in local_skills {
if let Some(name) = skill.get("name").and_then(|v| v.as_str()) {
if existing.insert(name.to_string()) {
skills.push(skill);
}
}
}
Ok(data)
}
fn scan_local_skill_entries() -> Result<Vec<Value>, String> {
let mut skills = Vec::new();

View File

@@ -193,19 +193,17 @@ pub fn run() {
messaging::save_agent_binding,
messaging::delete_agent_binding,
messaging::delete_agent_all_bindings,
// Skills 管理openclaw skills CLI
// Skills 管理
skills::skills_list,
skills::skills_info,
skills::skills_check,
skills::skills_install_dep,
skills::skills_skillhub_check,
skills::skills_skillhub_setup,
skills::skills_skillhub_search,
skills::skills_skillhub_install,
skills::skills_clawhub_search,
skills::skills_clawhub_install,
skills::skills_uninstall,
skills::skills_validate,
// SkillHub SDK内置 HTTP不依赖 CLI
skills::skillhub_search,
skills::skillhub_index,
skills::skillhub_install,
// 前端热更新
update::check_frontend_update,
update::download_frontend_update,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
"version": "0.11.4",
"version": "0.11.5",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",