feat: v0.9.2 — SkillHub双源技能管理、消息渠道多Agent绑定、模型配置优化、白屏安全网等

This commit is contained in:
晴天
2026-03-16 04:23:25 +08:00
parent b55ba4c14f
commit 48cffe1f42
25 changed files with 1088 additions and 276 deletions

View File

@@ -4,29 +4,86 @@ use serde_json::Value;
use std::fs;
use std::io::Write;
/// 获取 agent 列表
/// 获取 agent 列表(直接读 openclaw.json不走 CLI毫秒级响应
#[tauri::command]
pub async fn list_agents() -> Result<Value, String> {
let output = openclaw_command_async()
.args(["agents", "list", "--json"])
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。\n如果使用 nvm 安装,请从终端启动 ClawPanel。".to_string()
} else {
format!("执行失败: {e}")
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("获取 Agent 列表失败: {stderr}"));
let config_path = super::openclaw_dir().join("openclaw.json");
if !config_path.exists() {
return Err("openclaw.json 不存在,请先安装 OpenClaw".to_string());
}
let content = fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?;
let config: Value =
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
crate::commands::skills::extract_json_pub(&stdout)
.ok_or_else(|| "解析 JSON 失败: 输出中未找到有效 JSON".to_string())
let agents_list = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.cloned()
.unwrap_or_default();
// 补全 main agent 的 workspaceconfig 中可能没有显式指定)
let default_workspace = config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("workspace"))
.and_then(|w| w.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
super::openclaw_dir()
.join("workspace")
.to_string_lossy()
.to_string()
});
let enriched: Vec<Value> = agents_list
.into_iter()
.map(|mut agent| {
let id = agent
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// 补全 workspace 路径
if agent.get("workspace").and_then(|w| w.as_str()).is_none()
|| agent.get("workspace").and_then(|w| w.as_str()) == Some("")
{
if id == "main" {
agent.as_object_mut().map(|o| {
o.insert(
"workspace".to_string(),
Value::String(default_workspace.clone()),
)
});
} else {
let ws = super::openclaw_dir()
.join("agents")
.join(&id)
.join("workspace")
.to_string_lossy()
.to_string();
agent
.as_object_mut()
.map(|o| o.insert("workspace".to_string(), Value::String(ws)));
}
}
// 补全 identityName 用于前端显示
let identity_name = agent
.get("identity")
.and_then(|i| i.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
if !identity_name.is_empty() {
agent
.as_object_mut()
.map(|o| o.insert("identityName".to_string(), Value::String(identity_name)));
}
agent
})
.collect();
Ok(Value::Array(enriched))
}
/// 创建新 agent

View File

@@ -31,13 +31,26 @@ impl Drop for GuardianPause {
/// 预设 npm 源列表
const DEFAULT_REGISTRY: &str = "https://registry.npmmirror.com";
const GIT_HTTPS_REWRITES: [&str; 6] = [
"ssh://git@github.com/",
"ssh://git@github.com",
"ssh://git@://github.com/",
"git@github.com:",
"git://github.com/",
"git+ssh://git@github.com/",
/// (target_https_prefix, from_pattern) pairs for Git HTTPS rewriting.
/// Each entry maps a non-HTTPS Git URL pattern to the corresponding HTTPS URL.
const GIT_HTTPS_REWRITES: &[(&str, &str)] = &[
// github.com
("https://github.com/", "ssh://git@github.com/"),
("https://github.com/", "ssh://git@github.com"),
("https://github.com/", "ssh://git@://github.com/"),
("https://github.com/", "git@github.com:"),
("https://github.com/", "git://github.com/"),
("https://github.com/", "git+ssh://git@github.com/"),
// gitlab.com
("https://gitlab.com/", "ssh://git@gitlab.com/"),
("https://gitlab.com/", "git@gitlab.com:"),
("https://gitlab.com/", "git://gitlab.com/"),
("https://gitlab.com/", "git+ssh://git@gitlab.com/"),
// bitbucket.org
("https://bitbucket.org/", "ssh://git@bitbucket.org/"),
("https://bitbucket.org/", "git@bitbucket.org:"),
("https://bitbucket.org/", "git://bitbucket.org/"),
("https://bitbucket.org/", "git+ssh://git@bitbucket.org/"),
];
#[derive(Debug, Deserialize, Default)]
@@ -114,27 +127,23 @@ fn recommended_version_for(source: &str) -> Option<String> {
}
fn configure_git_https_rules() -> usize {
let mut unset = Command::new("git");
unset.args([
"config",
"--global",
"--unset-all",
"url.https://github.com/.insteadOf",
]);
#[cfg(target_os = "windows")]
unset.creation_flags(0x08000000);
let _ = unset.output();
// Collect unique target prefixes to unset old rules
let targets: std::collections::HashSet<&str> =
GIT_HTTPS_REWRITES.iter().map(|(t, _)| *t).collect();
for target in &targets {
let key = format!("url.{target}.insteadOf");
let mut unset = Command::new("git");
unset.args(["config", "--global", "--unset-all", &key]);
#[cfg(target_os = "windows")]
unset.creation_flags(0x08000000);
let _ = unset.output();
}
let mut success = 0;
for from in GIT_HTTPS_REWRITES {
for (target, from) in GIT_HTTPS_REWRITES {
let key = format!("url.{target}.insteadOf");
let mut cmd = Command::new("git");
cmd.args([
"config",
"--global",
"--add",
"url.https://github.com/.insteadOf",
from,
]);
cmd.args(["config", "--global", "--add", &key, from]);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
@@ -153,12 +162,12 @@ fn apply_git_install_env(cmd: &mut Command) {
)
.env("GIT_ALLOW_PROTOCOL", "https:http:file");
cmd.env("GIT_CONFIG_COUNT", GIT_HTTPS_REWRITES.len().to_string());
for (idx, from) in GIT_HTTPS_REWRITES.iter().enumerate() {
for (idx, (target, from)) in GIT_HTTPS_REWRITES.iter().enumerate() {
cmd.env(
format!("GIT_CONFIG_KEY_{idx}"),
"url.https://github.com/.insteadOf",
format!("url.{target}.insteadOf"),
)
.env(format!("GIT_CONFIG_VALUE_{idx}"), from);
.env(format!("GIT_CONFIG_VALUE_{idx}"), *from);
}
}

View File

@@ -1,8 +1,29 @@
use crate::utils::openclaw_command_async;
/// 记忆文件管理命令
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
/// 缓存 agent workspace 路径,避免每次操作都调 CLIWindows 上 spawn Node.js 进程很慢)
static WORKSPACE_CACHE: std::sync::LazyLock<Mutex<WorkspaceCache>> =
std::sync::LazyLock::new(|| Mutex::new(WorkspaceCache::default()));
#[derive(Default)]
struct WorkspaceCache {
map: HashMap<String, PathBuf>,
fetched_at: u64,
}
impl WorkspaceCache {
fn is_fresh(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now - self.fetched_at < 60 // 60 秒 TTL
}
}
/// 检查路径是否包含不安全字符(目录遍历、绝对路径等)
fn is_unsafe_path(path: &str) -> bool {
@@ -13,40 +34,82 @@ fn is_unsafe_path(path: &str) -> bool {
|| (path.len() >= 2 && path.as_bytes()[1] == b':') // Windows 绝对路径 C:\
}
/// 根据 agent_id 获取 workspace 路径(异步版本
/// 调用 openclaw agents list --json 解析
/// 根据 agent_id 获取 workspace 路径(直接读 openclaw.json带缓存
/// 不再调用 CLI毫秒级响应
async fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
let output = openclaw_command_async()
.args(["agents", "list", "--json"])
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。\n如果使用 nvm 安装,请从终端启动 ClawPanel。".to_string()
} else {
format!("执行 openclaw 失败: {e}")
// 先查缓存
{
let cache = WORKSPACE_CACHE.lock().unwrap();
if cache.is_fresh() {
if let Some(ws) = cache.map.get(agent_id) {
return Ok(ws.clone());
}
})?;
if !output.status.success() {
return Err("获取 Agent 列表失败".into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let agents: serde_json::Value = crate::commands::skills::extract_json_pub(&stdout)
.ok_or_else(|| "解析 JSON 失败: 输出中未找到有效 JSON".to_string())?;
if let Some(arr) = agents.as_array() {
for a in arr {
if a.get("id").and_then(|v| v.as_str()) == Some(agent_id) {
if let Some(ws) = a.get("workspace").and_then(|v| v.as_str()) {
return Ok(PathBuf::from(ws));
}
if !cache.map.is_empty() {
return Err(format!("Agent「{agent_id}」不存在或无 workspace"));
}
}
}
Err(format!("Agent「{agent_id}」不存在或无 workspace"))
// 缓存过期或为空,从 openclaw.json 读取
let config_path = super::openclaw_dir().join("openclaw.json");
let content =
fs::read_to_string(&config_path).map_err(|e| format!("读取 openclaw.json 失败: {e}"))?;
let config: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
let default_workspace = config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("workspace"))
.and_then(|w| w.as_str())
.map(PathBuf::from)
.unwrap_or_else(|| super::openclaw_dir().join("workspace"));
let mut new_map = HashMap::new();
// main agent 使用默认 workspace
new_map.insert("main".to_string(), default_workspace);
if let Some(arr) = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
{
for a in arr {
let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id.is_empty() {
continue;
}
let ws = a
.get("workspace")
.and_then(|v| v.as_str())
.map(PathBuf::from)
.unwrap_or_else(|| {
if id == "main" {
super::openclaw_dir().join("workspace")
} else {
super::openclaw_dir()
.join("agents")
.join(id)
.join("workspace")
}
});
new_map.insert(id.to_string(), ws);
}
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let result = new_map.get(agent_id).cloned();
{
let mut cache = WORKSPACE_CACHE.lock().unwrap();
cache.map = new_map;
cache.fetched_at = now;
}
result.ok_or_else(|| format!("Agent「{agent_id}」不存在或无 workspace"))
}
async fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result<PathBuf, String> {

View File

@@ -190,10 +190,12 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
/// 保存平台配置到 openclaw.json
/// 前端传入的是表单字段,后端负责转换成 OpenClaw 要求的结构
/// account_id: 可选,指定时写入 channels.<platform>.accounts.<account_id>(多账号模式)
#[tauri::command]
pub async fn save_messaging_platform(
platform: String,
form: Value,
account_id: Option<String>,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
@@ -338,7 +340,6 @@ pub async fn save_messaging_platform(
entry.insert("enabled".into(), Value::Bool(true));
entry.insert("connectionMode".into(), Value::String("websocket".into()));
// 域名(默认 feishu国际版选 lark
let domain = form_obj
.get("domain")
.and_then(|v| v.as_str())
@@ -349,7 +350,23 @@ pub async fn save_messaging_platform(
entry.insert("domain".into(), Value::String(domain));
}
channels_map.insert("feishu".into(), Value::Object(entry));
// 多账号模式:写入 channels.feishu.accounts.<account_id>
if let Some(ref acct) = account_id {
if !acct.is_empty() {
let feishu = channels_map
.entry("feishu")
.or_insert_with(|| json!({ "enabled": true }));
let feishu_obj = feishu.as_object_mut().ok_or("feishu 节点格式错误")?;
feishu_obj.entry("enabled").or_insert(Value::Bool(true));
let accounts = feishu_obj.entry("accounts").or_insert_with(|| json!({}));
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
accounts_obj.insert(acct.clone(), Value::Object(entry));
} else {
channels_map.insert("feishu".into(), Value::Object(entry));
}
} else {
channels_map.insert("feishu".into(), Value::Object(entry));
}
ensure_plugin_allowed(&mut cfg, "feishu")?;
let _ = cleanup_legacy_plugin_backup_dir("feishu");
}

View File

@@ -337,6 +337,13 @@ fn build_enhanced_path() -> String {
}
}
}
// NVM_SYMLINK 环境变量nvm-windows 的活跃版本符号链接,如 D:\nodejs
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
let symlink_path = std::path::Path::new(&nvm_symlink);
if symlink_path.is_dir() {
extra.push(nvm_symlink.clone());
}
}
// NVM_HOME 环境变量(用户可能自定义了 nvm 安装目录)
if let Ok(nvm_home) = std::env::var("NVM_HOME") {
let nvm_path = std::path::Path::new(&nvm_home);

View File

@@ -9,7 +9,7 @@ use std::os::windows::process::CommandExt;
#[tauri::command]
pub async fn skills_list() -> Result<Value, String> {
let output = openclaw_command_async()
.args(["skills", "list", "--json", "--verbose"])
.args(["skills", "list", "--json"])
.output()
.await;
@@ -137,29 +137,122 @@ pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, Stri
}))
}
/// 从 ClawHub 安装 Skillnpx clawhub install <slug>
/// 检测 SkillHub CLI 是否已安装
#[tauri::command]
pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
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 })),
}
}
/// 安装 SkillHub CLI从腾讯云 COS 下载)
#[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() }))
}
}
/// 从 SkillHub 安装 Skillskillhub install <slug>
#[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();
// 确保 skills 目录存在
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}"))?;
}
let mut cmd = tokio::process::Command::new("npx");
cmd.args(["-y", "clawhub", "install", &slug])
.env("PATH", &path_env)
.current_dir(&home);
super::apply_proxy_env_tokio(&mut cmd);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
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!("执行 clawhub 失败: {e}"))?;
.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();
@@ -175,25 +268,34 @@ pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
}))
}
/// 从 ClawHub 搜索 Skillsnpx clawhub search <query>
/// 从 SkillHub 搜索 Skillsskillhub search <query>
#[tauri::command]
pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
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();
let mut cmd = tokio::process::Command::new("npx");
cmd.args(["-y", "clawhub", "search", &q])
.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
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!("执行 clawhub 失败: {e}"))?;
.map_err(|e| format!("执行 skillhub 失败: {e}。请先安装 SkillHub CLI"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -202,7 +304,102 @@ pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
let stdout = String::from_utf8_lossy(&output.stdout);
// clawhub search 输出是文本行,每行一个 skill
// 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())
@@ -211,18 +408,63 @@ pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
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"
})
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> {
if name.is_empty() || name.contains("..") || name.contains('/') || name.contains('\\') {
return Err("无效的 Skill 名称".to_string());
}
let skills_dir = super::openclaw_dir().join("skills").join(&name);
if !skills_dir.exists() {
return Err(format!("Skill「{name}」不存在"));
}
std::fs::remove_dir_all(&skills_dir).map_err(|e| format!("删除失败: {e}"))?;
Ok(serde_json::json!({ "success": true, "name": name }))
}
/// Public wrapper for extract_json, used by config.rs get_status_summary
pub fn extract_json_pub(text: &str) -> Option<Value> {
extract_json(text)

View File

@@ -165,8 +165,13 @@ pub fn run() {
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,
// 前端热更新
update::check_frontend_update,
update::download_frontend_update,