Feat/claude skills management (#237)
* feat(skills): add Claude Skills management feature Implement complete Skills management system with repository discovery, installation, and lifecycle management capabilities. Backend: - Add SkillService with GitHub integration and installation logic - Implement skill commands (list, install, uninstall, check updates) - Support multiple skill repositories with caching Frontend: - Add Skills management page with repository browser - Create SkillCard and RepoManager components - Add badge, card, table UI components - Integrate Skills API with Tauri commands Files: 10 files changed, 1488 insertions(+) * feat(skills): integrate Skills feature into application Integrate Skills management feature with complete dependency updates, configuration structure extensions, and internationalization support. Dependencies: - Add @radix-ui/react-visually-hidden for accessibility - Add anyhow, zip, serde_yaml, tempfile for Skills backend - Enable chrono serde feature for timestamp serialization Backend Integration: - Extend MultiAppConfig with SkillStore field - Implement skills.json migration from legacy location - Register SkillService and skill commands in main app - Export skill module in commands and services Frontend Integration: - Add Skills page route and dialog in App - Integrate Skills UI with main navigation Internationalization: - Add complete Chinese translations for Skills UI - Add complete English translations for Skills UI Code Quality: - Remove redundant blank lines in gemini_mcp.rs - Format log statements in mcp.rs Tests: - Update import_export_sync tests for SkillStore - Update mcp_commands tests for new structure Files: 16 files changed, 540 insertions(+), 39 deletions(-) * style(skills): improve SkillsPage typography and spacing Optimize visual hierarchy and readability of Skills page: - Reduce title size from 2xl to lg with tighter tracking - Improve description spacing and color contrast - Enhance empty state with better text hierarchy - Use explicit gray colors for better dark mode support * feat(skills): support custom subdirectory path for skill scanning Add optional skillsPath field to SkillRepo to enable scanning skills from subdirectories (e.g., "skills/") instead of repository root. Changes: - Backend: Add skillsPath field with subdirectory scanning logic - Frontend: Add skillsPath input field and display in repo list - Presets: Add cexll/myclaude repo with skills/ subdirectory - Code quality: Fix clippy warnings (dedup logic, string formatting) Backward compatible: skillsPath is optional, defaults to root scanning. * refactor(skills): improve repo manager dialog layout Optimize dialog structure with fixed header and scrollable content: - Add flexbox layout with fixed header and scrollable body - Remove outer border wrapper for cleaner appearance - Match SkillsPage design pattern for consistency - Improve UX with better content hierarchy
This commit is contained in:
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::services::skill::SkillStore;
|
||||
|
||||
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct McpApps {
|
||||
@@ -221,6 +223,9 @@ pub struct MultiAppConfig {
|
||||
/// Prompt 配置(按客户端分治)
|
||||
#[serde(default)]
|
||||
pub prompts: PromptRoot,
|
||||
/// Claude Skills 配置
|
||||
#[serde(default)]
|
||||
pub skills: SkillStore,
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[serde(default)]
|
||||
pub common_config_snippets: CommonConfigSnippets,
|
||||
@@ -245,6 +250,7 @@ impl Default for MultiAppConfig {
|
||||
apps,
|
||||
mcp: McpRoot::default(),
|
||||
prompts: PromptRoot::default(),
|
||||
skills: SkillStore::default(),
|
||||
common_config_snippets: CommonConfigSnippets::default(),
|
||||
claude_common_config_snippet: None,
|
||||
}
|
||||
@@ -288,11 +294,36 @@ impl MultiAppConfig {
|
||||
));
|
||||
}
|
||||
|
||||
let has_skills_in_config = value
|
||||
.as_object()
|
||||
.is_some_and(|map| map.contains_key("skills"));
|
||||
|
||||
// 解析 v2 结构
|
||||
let mut config: Self =
|
||||
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||
let mut updated = false;
|
||||
|
||||
if !has_skills_in_config {
|
||||
let skills_path = get_app_config_dir().join("skills.json");
|
||||
if skills_path.exists() {
|
||||
match std::fs::read_to_string(&skills_path) {
|
||||
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
|
||||
Ok(store) => {
|
||||
config.skills = store;
|
||||
updated = true;
|
||||
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("解析旧版 skills.json 失败: {e}");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("读取旧版 skills.json 失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||
if !config.apps.contains_key("gemini") {
|
||||
config
|
||||
|
||||
@@ -8,6 +8,7 @@ mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
|
||||
pub use config::*;
|
||||
pub use import_export::*;
|
||||
@@ -17,3 +18,4 @@ pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
|
||||
163
src-tauri/src/commands/skill.rs
Normal file
163
src-tauri/src/commands/skill.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use crate::services::skill::SkillState;
|
||||
use crate::services::{Skill, SkillRepo, SkillService};
|
||||
use crate::store::AppState;
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_skills(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let repos = {
|
||||
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||
config.skills.repos.clone()
|
||||
};
|
||||
|
||||
service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||
let repos = {
|
||||
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||
config.skills.repos.clone()
|
||||
};
|
||||
|
||||
let skills = service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let skill = skills
|
||||
.iter()
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| "技能不存在".to_string())?;
|
||||
|
||||
if !skill.installed {
|
||||
let repo = SkillRepo {
|
||||
owner: skill
|
||||
.repo_owner
|
||||
.clone()
|
||||
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||
name: skill
|
||||
.repo_name
|
||||
.clone()
|
||||
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||
branch: skill
|
||||
.repo_branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| "main".to_string()),
|
||||
enabled: true,
|
||||
skills_path: None, // 安装时使用默认路径
|
||||
};
|
||||
|
||||
service
|
||||
.0
|
||||
.install_skill(directory.clone(), repo)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
config.skills.skills.insert(
|
||||
directory.clone(),
|
||||
SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
service
|
||||
.0
|
||||
.uninstall_skill(directory.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
config.skills.skills.remove(&directory);
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_skill_repos(
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<SkillRepo>, String> {
|
||||
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(config.skills.repos.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_skill_repo(
|
||||
repo: SkillRepo,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
service
|
||||
.0
|
||||
.add_repo(&mut config.skills, repo)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_skill_repo(
|
||||
owner: String,
|
||||
name: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
service
|
||||
.0
|
||||
.remove_repo(&mut config.skills, owner, name)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -31,11 +31,13 @@ pub use mcp::{
|
||||
};
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use services::{
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SpeedtestService,
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
||||
SpeedtestService,
|
||||
};
|
||||
pub use settings::{update_settings, AppSettings};
|
||||
pub use store::AppState;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
@@ -495,6 +497,17 @@ pub fn run() {
|
||||
let _tray = tray_builder.build(app)?;
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
|
||||
// 初始化 SkillService
|
||||
match SkillService::new() {
|
||||
Ok(skill_service) => {
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("初始化 SkillService 失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -573,6 +586,13 @@ pub fn run() {
|
||||
commands::open_file_dialog,
|
||||
commands::sync_current_providers_live,
|
||||
update_tray_menu,
|
||||
// Skill management
|
||||
commands::get_skills,
|
||||
commands::install_skill,
|
||||
commands::uninstall_skill,
|
||||
commands::get_skill_repos,
|
||||
commands::add_skill_repo,
|
||||
commands::remove_skill_repo,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -2,10 +2,12 @@ pub mod config;
|
||||
pub mod mcp;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use skill::{Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
|
||||
526
src-tauri/src/services/skill.rs
Normal file
526
src-tauri/src/services/skill.rs
Normal file
@@ -0,0 +1,526 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 技能对象
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Skill {
|
||||
/// 唯一标识: "owner/name:directory" 或 "local:directory"
|
||||
pub key: String,
|
||||
/// 显示名称 (从 SKILL.md 解析)
|
||||
pub name: String,
|
||||
/// 技能描述
|
||||
pub description: String,
|
||||
/// 目录名称 (安装路径的最后一段)
|
||||
pub directory: String,
|
||||
/// GitHub README URL
|
||||
#[serde(rename = "readmeUrl")]
|
||||
pub readme_url: Option<String>,
|
||||
/// 是否已安装
|
||||
pub installed: bool,
|
||||
/// 仓库所有者
|
||||
#[serde(rename = "repoOwner")]
|
||||
pub repo_owner: Option<String>,
|
||||
/// 仓库名称
|
||||
#[serde(rename = "repoName")]
|
||||
pub repo_name: Option<String>,
|
||||
/// 分支名称
|
||||
#[serde(rename = "repoBranch")]
|
||||
pub repo_branch: Option<String>,
|
||||
}
|
||||
|
||||
/// 仓库配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillRepo {
|
||||
/// GitHub 用户/组织名
|
||||
pub owner: String,
|
||||
/// 仓库名称
|
||||
pub name: String,
|
||||
/// 分支 (默认 "main")
|
||||
pub branch: String,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
|
||||
#[serde(rename = "skillsPath")]
|
||||
pub skills_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 技能安装状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillState {
|
||||
/// 是否已安装
|
||||
pub installed: bool,
|
||||
/// 安装时间
|
||||
#[serde(rename = "installedAt")]
|
||||
pub installed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 持久化存储结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillStore {
|
||||
/// directory -> 安装状态
|
||||
pub skills: HashMap<String, SkillState>,
|
||||
/// 仓库列表
|
||||
pub repos: Vec<SkillRepo>,
|
||||
}
|
||||
|
||||
impl Default for SkillStore {
|
||||
fn default() -> Self {
|
||||
SkillStore {
|
||||
skills: HashMap::new(),
|
||||
repos: vec![
|
||||
SkillRepo {
|
||||
owner: "ComposioHQ".to_string(),
|
||||
name: "awesome-claude-skills".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
skills_path: None, // 扫描根目录
|
||||
},
|
||||
SkillRepo {
|
||||
owner: "anthropics".to_string(),
|
||||
name: "skills".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
skills_path: None, // 扫描根目录
|
||||
},
|
||||
SkillRepo {
|
||||
owner: "cexll".to_string(),
|
||||
name: "myclaude".to_string(),
|
||||
branch: "master".to_string(),
|
||||
enabled: true,
|
||||
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 技能元数据 (从 SKILL.md 解析)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SkillMetadata {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SkillService {
|
||||
http_client: Client,
|
||||
install_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SkillService {
|
||||
pub fn new() -> Result<Self> {
|
||||
let install_dir = Self::get_install_dir()?;
|
||||
|
||||
// 确保目录存在
|
||||
fs::create_dir_all(&install_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
http_client: Client::builder()
|
||||
.user_agent("cc-switch")
|
||||
// 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?,
|
||||
install_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_install_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
||||
Ok(home.join(".claude").join("skills"))
|
||||
}
|
||||
}
|
||||
|
||||
// 核心方法实现
|
||||
impl SkillService {
|
||||
/// 列出所有技能
|
||||
pub async fn list_skills(&self, repos: Vec<SkillRepo>) -> Result<Vec<Skill>> {
|
||||
let mut skills = Vec::new();
|
||||
|
||||
// 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新
|
||||
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
|
||||
|
||||
let fetch_tasks = enabled_repos
|
||||
.iter()
|
||||
.map(|repo| self.fetch_repo_skills(repo));
|
||||
|
||||
let results: Vec<Result<Vec<Skill>>> = futures::future::join_all(fetch_tasks).await;
|
||||
|
||||
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
|
||||
match result {
|
||||
Ok(repo_skills) => skills.extend(repo_skills),
|
||||
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
|
||||
}
|
||||
}
|
||||
|
||||
// 合并本地技能
|
||||
self.merge_local_skills(&mut skills)?;
|
||||
|
||||
// 去重并排序
|
||||
Self::deduplicate_skills(&mut skills);
|
||||
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 从仓库获取技能列表
|
||||
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
|
||||
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
|
||||
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
|
||||
.await
|
||||
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||
let mut skills = Vec::new();
|
||||
|
||||
// 确定要扫描的目录路径
|
||||
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
|
||||
// 如果指定了 skillsPath,则扫描该子目录
|
||||
let subdir = temp_dir.join(skills_path.trim_matches('/'));
|
||||
if !subdir.exists() {
|
||||
log::warn!(
|
||||
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
skills_path
|
||||
);
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Ok(skills);
|
||||
}
|
||||
subdir
|
||||
} else {
|
||||
// 否则扫描仓库根目录
|
||||
temp_dir.clone()
|
||||
};
|
||||
|
||||
// 遍历目标目录
|
||||
for entry in fs::read_dir(&scan_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析技能元数据
|
||||
match self.parse_skill_metadata(&skill_md) {
|
||||
Ok(meta) => {
|
||||
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
|
||||
// 构建 README URL(考虑 skillsPath)
|
||||
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||
format!("{}/{}", skills_path.trim_matches('/'), directory)
|
||||
} else {
|
||||
directory.clone()
|
||||
};
|
||||
|
||||
skills.push(Skill {
|
||||
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory,
|
||||
readme_url: Some(format!(
|
||||
"https://github.com/{}/{}/tree/{}/{}",
|
||||
repo.owner, repo.name, repo.branch, readme_path
|
||||
)),
|
||||
installed: false,
|
||||
repo_owner: Some(repo.owner.clone()),
|
||||
repo_name: Some(repo.name.clone()),
|
||||
repo_branch: Some(repo.branch.clone()),
|
||||
});
|
||||
}
|
||||
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 解析技能元数据
|
||||
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
|
||||
// 移除 BOM
|
||||
let content = content.trim_start_matches('\u{feff}');
|
||||
|
||||
// 提取 YAML front matter
|
||||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||
if parts.len() < 3 {
|
||||
return Ok(SkillMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
});
|
||||
}
|
||||
|
||||
let front_matter = parts[1].trim();
|
||||
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
});
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 合并本地技能
|
||||
fn merge_local_skills(&self, skills: &mut Vec<Skill>) -> Result<()> {
|
||||
if !self.install_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&self.install_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
|
||||
// 更新已安装状态
|
||||
let mut found = false;
|
||||
for skill in skills.iter_mut() {
|
||||
if skill.directory.eq_ignore_ascii_case(&directory) {
|
||||
skill.installed = true;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加本地独有的技能(仅当在仓库中未找到时)
|
||||
if !found {
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
|
||||
skills.push(Skill {
|
||||
key: format!("local:{directory}"),
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory: directory.clone(),
|
||||
readme_url: None,
|
||||
installed: true,
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 去重技能列表
|
||||
fn deduplicate_skills(skills: &mut Vec<Skill>) {
|
||||
let mut seen = HashMap::new();
|
||||
skills.retain(|skill| {
|
||||
let key = skill.directory.to_lowercase();
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
|
||||
e.insert(true);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 下载仓库
|
||||
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理
|
||||
|
||||
// 尝试多个分支
|
||||
let branches = if repo.branch.is_empty() {
|
||||
vec!["main", "master"]
|
||||
} else {
|
||||
vec![repo.branch.as_str(), "main", "master"]
|
||||
};
|
||||
|
||||
let mut last_error = None;
|
||||
for branch in branches {
|
||||
let url = format!(
|
||||
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
||||
repo.owner, repo.name, branch
|
||||
);
|
||||
|
||||
match self.download_and_extract(&url, &temp_path).await {
|
||||
Ok(_) => {
|
||||
return Ok(temp_path);
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
|
||||
}
|
||||
|
||||
/// 下载并解压 ZIP
|
||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||
// 下载 ZIP
|
||||
let response = self.http_client.get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
// 解压
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor)?;
|
||||
|
||||
// 获取根目录名称 (GitHub 的 zip 会有一个根目录)
|
||||
let root_name = if !archive.is_empty() {
|
||||
let first_file = archive.by_index(0)?;
|
||||
let name = first_file.name();
|
||||
name.split('/').next().unwrap_or("").to_string()
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("空的压缩包"));
|
||||
};
|
||||
|
||||
// 解压所有文件
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let file_path = file.name();
|
||||
|
||||
// 跳过根目录,直接提取内容
|
||||
let relative_path =
|
||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||
stripped
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if relative_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let outpath = dest.join(relative_path);
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
std::io::copy(&mut file, &mut outfile)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
||||
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
||||
let dest = self.install_dir.join(&directory);
|
||||
|
||||
// 若目标目录已存在,则视为已安装,避免重复下载
|
||||
if dest.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
|
||||
let temp_dir = timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
self.download_repo(&repo),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||
|
||||
// 复制到安装目录
|
||||
let source = temp_dir.join(&directory);
|
||||
|
||||
if !source.exists() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow::anyhow!("技能目录不存在"));
|
||||
}
|
||||
|
||||
// 删除旧版本
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
|
||||
// 递归复制
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归复制目录
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dest)?;
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
|
||||
if path.is_dir() {
|
||||
Self::copy_dir_recursive(&path, &dest_path)?;
|
||||
} else {
|
||||
fs::copy(&path, &dest_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
||||
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
||||
let dest = self.install_dir.join(&directory);
|
||||
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出仓库
|
||||
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
|
||||
store.repos.clone()
|
||||
}
|
||||
|
||||
/// 添加仓库
|
||||
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
|
||||
// 检查重复
|
||||
if let Some(pos) = store
|
||||
.repos
|
||||
.iter()
|
||||
.position(|r| r.owner == repo.owner && r.name == repo.name)
|
||||
{
|
||||
store.repos[pos] = repo;
|
||||
} else {
|
||||
store.repos.push(repo);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除仓库
|
||||
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
|
||||
store
|
||||
.repos
|
||||
.retain(|r| !(r.owner == owner && r.name == name));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user