mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-03-24 16:33:48 +08:00
* fix(skill): resolve symlinks in ZIP extraction for GitHub repos (#1001) - detect symlink entries via is_symlink() during ZIP extraction and collect target paths - add resolve_symlinks_in_dir() to copy symlink target content into link location - canonicalize base_dir to fix macOS /tmp → /private/tmp path comparison issue - add path traversal safety check to block symlinks pointing outside repo boundary - apply symlink resolution to both download_and_extract and extract_local_zip paths Closes https://github.com/farion1231/cc-switch/issues/1001 * fix(skill): change search to match name and repo instead of description * feat(skill): support importing skills from ~/.agents/skills/ directory - Scan ~/.agents/skills/ in scan_unmanaged() for skill discovery - Parse ~/.agents/.skill-lock.json to extract repo owner/name metadata - Auto-add discovered repos to skill_repos management on import - Add path field to UnmanagedSkill to show discovered location in UI Closes #980 * fix(skill): use metadata name or ZIP filename for root-level SKILL.md imports (#1000) When a ZIP contains SKILL.md at the root without a wrapper directory, the install name was derived from the temp directory name (e.g. .tmpDZKGpF). Now falls back to SKILL.md frontmatter name, then ZIP filename stem. * feat(skill): scan ~/.cc-switch/skills/ for unmanaged skill discovery and import * refactor(skill): unify scan/import logic with lock file skillPath and repo saving - Deduplicate scan_unmanaged and import_from_apps using shared source list - Replace hand-written AppType match with as_str() and AppType::all() - Extract read_skill_name_desc, build_repo_info_from_lock, save_repos_from_lock helpers - Add SkillApps::from_labels for building enable state from source labels - Parse skillPath from .skill-lock.json for correct readme URLs - Save skill repos to skill_repos table in both import and migration paths * fix(skill): resolve symlink and path traversal issues in ZIP skill import * fix(skill): separate source path validation and add canonicalization for symlink safety
1892 lines
63 KiB
Rust
1892 lines
63 KiB
Rust
//! Skills 服务层
|
||
//!
|
||
//! v3.10.0+ 统一管理架构:
|
||
//! - SSOT(单一事实源):`~/.cc-switch/skills/`
|
||
//! - 安装时下载到 SSOT,按需同步到各应用目录
|
||
//! - 数据库存储安装记录和启用状态
|
||
|
||
use anyhow::{anyhow, Context, Result};
|
||
use chrono::{DateTime, Utc};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::fs;
|
||
use std::path::{Component, Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use tokio::time::timeout;
|
||
|
||
use crate::app_config::{AppType, InstalledSkill, SkillApps, UnmanagedSkill};
|
||
use crate::config::get_app_config_dir;
|
||
use crate::database::Database;
|
||
use crate::error::format_skill_error;
|
||
|
||
// ========== 数据结构 ==========
|
||
|
||
/// Skill 同步方式
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum SyncMethod {
|
||
/// 自动选择:优先 symlink,失败时回退到 copy
|
||
#[default]
|
||
Auto,
|
||
/// 符号链接(推荐,节省磁盘空间)
|
||
Symlink,
|
||
/// 文件复制(兼容模式)
|
||
Copy,
|
||
}
|
||
|
||
/// 可发现的技能(来自仓库)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DiscoverableSkill {
|
||
/// 唯一标识: "owner/name: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>,
|
||
/// 仓库所有者
|
||
#[serde(rename = "repoOwner")]
|
||
pub repo_owner: String,
|
||
/// 仓库名称
|
||
#[serde(rename = "repoName")]
|
||
pub repo_name: String,
|
||
/// 分支名称
|
||
#[serde(rename = "repoBranch")]
|
||
pub repo_branch: String,
|
||
}
|
||
|
||
/// 技能对象(兼容旧 API,内部使用 DiscoverableSkill)
|
||
#[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,
|
||
}
|
||
|
||
/// 技能安装状态(旧版兼容)
|
||
#[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: "anthropics".to_string(),
|
||
name: "skills".to_string(),
|
||
branch: "main".to_string(),
|
||
enabled: true,
|
||
},
|
||
SkillRepo {
|
||
owner: "ComposioHQ".to_string(),
|
||
name: "awesome-claude-skills".to_string(),
|
||
branch: "master".to_string(),
|
||
enabled: true,
|
||
},
|
||
SkillRepo {
|
||
owner: "cexll".to_string(),
|
||
name: "myclaude".to_string(),
|
||
branch: "master".to_string(),
|
||
enabled: true,
|
||
},
|
||
SkillRepo {
|
||
owner: "JimLiu".to_string(),
|
||
name: "baoyu-skills".to_string(),
|
||
branch: "main".to_string(),
|
||
enabled: true,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 技能元数据 (从 SKILL.md 解析)
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct SkillMetadata {
|
||
pub name: Option<String>,
|
||
pub description: Option<String>,
|
||
}
|
||
|
||
// ========== ~/.agents/ lock 文件解析 ==========
|
||
|
||
/// `~/.agents/.skill-lock.json` 文件结构
|
||
#[derive(Deserialize)]
|
||
struct AgentsLockFile {
|
||
skills: HashMap<String, AgentsLockSkill>,
|
||
}
|
||
|
||
/// lock 文件中单个 skill 的信息
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentsLockSkill {
|
||
source: Option<String>,
|
||
source_type: Option<String>,
|
||
source_url: Option<String>,
|
||
skill_path: Option<String>,
|
||
branch: Option<String>,
|
||
source_branch: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct LockRepoInfo {
|
||
owner: String,
|
||
repo: String,
|
||
skill_path: Option<String>,
|
||
branch: Option<String>,
|
||
}
|
||
|
||
fn normalize_optional_branch(branch: Option<String>) -> Option<String> {
|
||
branch.and_then(|b| {
|
||
let trimmed = b.trim();
|
||
if trimmed.is_empty() {
|
||
None
|
||
} else {
|
||
Some(trimmed.to_string())
|
||
}
|
||
})
|
||
}
|
||
|
||
fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
|
||
let source_url = source_url?;
|
||
let source_url = source_url.trim();
|
||
if source_url.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// 支持 https://github.com/owner/repo/tree/<branch>/...
|
||
if let Some((_, after_tree)) = source_url.split_once("/tree/") {
|
||
let branch = after_tree
|
||
.split('/')
|
||
.next()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())?;
|
||
return Some(branch.to_string());
|
||
}
|
||
|
||
// 支持 URL fragment: ...git#branch
|
||
if let Some((_, fragment)) = source_url.split_once('#') {
|
||
let branch = fragment
|
||
.split('&')
|
||
.next()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())?;
|
||
return Some(branch.to_string());
|
||
}
|
||
|
||
// 支持 query: ...?branch=xxx / ?ref=xxx
|
||
if let Some((_, query)) = source_url.split_once('?') {
|
||
for pair in query.split('&') {
|
||
let Some((key, value)) = pair.split_once('=') else {
|
||
continue;
|
||
};
|
||
if matches!(key, "branch" | "ref") {
|
||
let branch = value.trim();
|
||
if !branch.is_empty() {
|
||
return Some(branch.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// 获取 `~/.agents/skills/` 目录(存在时返回)
|
||
fn get_agents_skills_dir() -> Option<PathBuf> {
|
||
dirs::home_dir()
|
||
.map(|h| h.join(".agents").join("skills"))
|
||
.filter(|p| p.exists())
|
||
}
|
||
|
||
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
|
||
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
|
||
let path = match dirs::home_dir() {
|
||
Some(h) => h.join(".agents").join(".skill-lock.json"),
|
||
None => {
|
||
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
|
||
return HashMap::new();
|
||
}
|
||
};
|
||
let content = match fs::read_to_string(&path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
if e.kind() == std::io::ErrorKind::NotFound {
|
||
log::debug!("未找到 agents lock 文件: {}", path.display());
|
||
} else {
|
||
log::warn!("读取 agents lock 文件失败 ({}): {}", path.display(), e);
|
||
}
|
||
return HashMap::new();
|
||
}
|
||
};
|
||
let lock: AgentsLockFile = match serde_json::from_str(&content) {
|
||
Ok(l) => l,
|
||
Err(e) => {
|
||
log::warn!("解析 agents lock 文件失败 ({}): {}", path.display(), e);
|
||
return HashMap::new();
|
||
}
|
||
};
|
||
let parsed: HashMap<String, LockRepoInfo> = lock
|
||
.skills
|
||
.into_iter()
|
||
.filter_map(|(name, skill)| {
|
||
let source = skill.source?;
|
||
if skill.source_type.as_deref() != Some("github") {
|
||
return None;
|
||
}
|
||
let (owner, repo) = source.split_once('/')?;
|
||
let branch = normalize_optional_branch(skill.branch)
|
||
.or_else(|| normalize_optional_branch(skill.source_branch))
|
||
.or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));
|
||
Some((
|
||
name,
|
||
LockRepoInfo {
|
||
owner: owner.to_string(),
|
||
repo: repo.to_string(),
|
||
skill_path: skill.skill_path,
|
||
branch,
|
||
},
|
||
))
|
||
})
|
||
.collect();
|
||
log::info!(
|
||
"agents lock 文件解析完成,共识别 {} 个 github skill",
|
||
parsed.len()
|
||
);
|
||
parsed
|
||
}
|
||
|
||
// ========== SkillService ==========
|
||
|
||
pub struct SkillService;
|
||
|
||
impl Default for SkillService {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl SkillService {
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
/// 构建 Skill 文档 URL(指向仓库中的 SKILL.md 文件)
|
||
fn build_skill_doc_url(owner: &str, repo: &str, branch: &str, doc_path: &str) -> String {
|
||
format!("https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}")
|
||
}
|
||
|
||
/// 从旧 readme_url 中提取仓库内文档路径,兼容 `blob`/`tree` 两种格式
|
||
fn extract_doc_path_from_url(url: &str) -> Option<String> {
|
||
let marker = if url.contains("/blob/") {
|
||
"/blob/"
|
||
} else if url.contains("/tree/") {
|
||
"/tree/"
|
||
} else {
|
||
return None;
|
||
};
|
||
|
||
let (_, tail) = url.split_once(marker)?;
|
||
let (_, path) = tail.split_once('/')?;
|
||
if path.is_empty() {
|
||
return None;
|
||
}
|
||
Some(path.to_string())
|
||
}
|
||
|
||
// ========== 路径管理 ==========
|
||
|
||
/// 获取 SSOT 目录(~/.cc-switch/skills/)
|
||
pub fn get_ssot_dir() -> Result<PathBuf> {
|
||
let dir = get_app_config_dir().join("skills");
|
||
fs::create_dir_all(&dir)?;
|
||
Ok(dir)
|
||
}
|
||
|
||
/// 获取应用的 skills 目录
|
||
pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf> {
|
||
// 目录覆盖:优先使用用户在 settings.json 中配置的 override 目录
|
||
match app {
|
||
AppType::Claude => {
|
||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||
return Ok(custom.join("skills"));
|
||
}
|
||
}
|
||
AppType::Codex => {
|
||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||
return Ok(custom.join("skills"));
|
||
}
|
||
}
|
||
AppType::Gemini => {
|
||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||
return Ok(custom.join("skills"));
|
||
}
|
||
}
|
||
AppType::OpenCode => {
|
||
if let Some(custom) = crate::settings::get_opencode_override_dir() {
|
||
return Ok(custom.join("skills"));
|
||
}
|
||
}
|
||
AppType::OpenClaw => {
|
||
if let Some(custom) = crate::settings::get_openclaw_override_dir() {
|
||
return Ok(custom.join("skills"));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 默认路径:回退到用户主目录下的标准位置
|
||
let home = dirs::home_dir().context(format_skill_error(
|
||
"GET_HOME_DIR_FAILED",
|
||
&[],
|
||
Some("checkPermission"),
|
||
))?;
|
||
|
||
Ok(match app {
|
||
AppType::Claude => home.join(".claude").join("skills"),
|
||
AppType::Codex => home.join(".codex").join("skills"),
|
||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||
AppType::OpenClaw => home.join(".openclaw").join("skills"),
|
||
})
|
||
}
|
||
|
||
// ========== 统一管理方法 ==========
|
||
|
||
/// 获取所有已安装的 Skills
|
||
pub fn get_all_installed(db: &Arc<Database>) -> Result<Vec<InstalledSkill>> {
|
||
let skills = db.get_all_installed_skills()?;
|
||
Ok(skills.into_values().collect())
|
||
}
|
||
|
||
/// 安装 Skill
|
||
///
|
||
/// 流程:
|
||
/// 1. 下载到 SSOT 目录
|
||
/// 2. 保存到数据库
|
||
/// 3. 同步到启用的应用目录
|
||
pub async fn install(
|
||
&self,
|
||
db: &Arc<Database>,
|
||
skill: &DiscoverableSkill,
|
||
current_app: &AppType,
|
||
) -> Result<InstalledSkill> {
|
||
let ssot_dir = Self::get_ssot_dir()?;
|
||
|
||
// 允许多级目录(如 a/b/c),但必须是安全的相对路径。
|
||
let source_rel = Self::sanitize_skill_source_path(&skill.directory).ok_or_else(|| {
|
||
anyhow!(format_skill_error(
|
||
"INVALID_SKILL_DIRECTORY",
|
||
&[("directory", &skill.directory)],
|
||
Some("checkZipContent"),
|
||
))
|
||
})?;
|
||
// 安装目录名始终使用最后一段,避免在 SSOT 中创建多级目录。
|
||
let install_name = source_rel
|
||
.file_name()
|
||
.and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))
|
||
.ok_or_else(|| {
|
||
anyhow!(format_skill_error(
|
||
"INVALID_SKILL_DIRECTORY",
|
||
&[("directory", &skill.directory)],
|
||
Some("checkZipContent"),
|
||
))
|
||
})?;
|
||
|
||
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
||
let existing_skills = db.get_all_installed_skills()?;
|
||
for existing in existing_skills.values() {
|
||
if existing.directory.eq_ignore_ascii_case(&install_name) {
|
||
// 检查是否来自同一仓库
|
||
let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)
|
||
&& existing.repo_name.as_deref() == Some(&skill.repo_name);
|
||
if same_repo {
|
||
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
|
||
let mut updated = existing.clone();
|
||
updated.apps.set_enabled_for(current_app, true);
|
||
db.save_skill(&updated)?;
|
||
Self::sync_to_app_dir(&updated.directory, current_app)?;
|
||
log::info!(
|
||
"Skill {} 已存在,更新 {:?} 启用状态",
|
||
updated.name,
|
||
current_app
|
||
);
|
||
return Ok(updated);
|
||
} else {
|
||
// 不同仓库的同名 skill,报错
|
||
return Err(anyhow!(format_skill_error(
|
||
"SKILL_DIRECTORY_CONFLICT",
|
||
&[
|
||
("directory", &install_name),
|
||
(
|
||
"existing_repo",
|
||
&format!(
|
||
"{}/{}",
|
||
existing.repo_owner.as_deref().unwrap_or("unknown"),
|
||
existing.repo_name.as_deref().unwrap_or("unknown")
|
||
)
|
||
),
|
||
(
|
||
"new_repo",
|
||
&format!("{}/{}", skill.repo_owner, skill.repo_name)
|
||
),
|
||
],
|
||
Some("uninstallFirst"),
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
let dest = ssot_dir.join(&install_name);
|
||
|
||
let mut repo_branch = skill.repo_branch.clone();
|
||
|
||
// 如果已存在则跳过下载
|
||
if !dest.exists() {
|
||
let repo = SkillRepo {
|
||
owner: skill.repo_owner.clone(),
|
||
name: skill.repo_name.clone(),
|
||
branch: skill.repo_branch.clone(),
|
||
enabled: true,
|
||
};
|
||
|
||
// 下载仓库
|
||
let (temp_dir, used_branch) = timeout(
|
||
std::time::Duration::from_secs(60),
|
||
self.download_repo(&repo),
|
||
)
|
||
.await
|
||
.map_err(|_| {
|
||
anyhow!(format_skill_error(
|
||
"DOWNLOAD_TIMEOUT",
|
||
&[
|
||
("owner", &repo.owner),
|
||
("name", &repo.name),
|
||
("timeout", "60")
|
||
],
|
||
Some("checkNetwork"),
|
||
))
|
||
})??;
|
||
repo_branch = used_branch;
|
||
|
||
// 复制到 SSOT
|
||
let source = temp_dir.join(&source_rel);
|
||
if !source.exists() {
|
||
let _ = fs::remove_dir_all(&temp_dir);
|
||
return Err(anyhow!(format_skill_error(
|
||
"SKILL_DIR_NOT_FOUND",
|
||
&[("path", &source.display().to_string())],
|
||
Some("checkRepoUrl"),
|
||
)));
|
||
}
|
||
|
||
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
|
||
let canonical_source = source.canonicalize().map_err(|_| {
|
||
anyhow!(format_skill_error(
|
||
"SKILL_DIR_NOT_FOUND",
|
||
&[("path", &source.display().to_string())],
|
||
Some("checkRepoUrl"),
|
||
))
|
||
})?;
|
||
if !canonical_source.starts_with(&canonical_temp) || !canonical_source.is_dir() {
|
||
let _ = fs::remove_dir_all(&temp_dir);
|
||
return Err(anyhow!(format_skill_error(
|
||
"INVALID_SKILL_DIRECTORY",
|
||
&[("directory", &skill.directory)],
|
||
Some("checkZipContent"),
|
||
)));
|
||
}
|
||
|
||
Self::copy_dir_recursive(&canonical_source, &dest)?;
|
||
let _ = fs::remove_dir_all(&temp_dir);
|
||
|
||
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
|
||
if repo_branch != skill.repo_branch {
|
||
log::info!(
|
||
"Skill {}/{} 分支自动回退: {} -> {}",
|
||
skill.repo_owner,
|
||
skill.repo_name,
|
||
skill.repo_branch,
|
||
repo_branch
|
||
);
|
||
}
|
||
}
|
||
|
||
let doc_path = skill
|
||
.readme_url
|
||
.as_deref()
|
||
.and_then(Self::extract_doc_path_from_url)
|
||
.map(|path| {
|
||
if path.ends_with("/SKILL.md") || path == "SKILL.md" {
|
||
path
|
||
} else {
|
||
format!("{}/SKILL.md", path.trim_end_matches('/'))
|
||
}
|
||
})
|
||
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
|
||
|
||
let readme_url = Some(Self::build_skill_doc_url(
|
||
&skill.repo_owner,
|
||
&skill.repo_name,
|
||
&repo_branch,
|
||
&doc_path,
|
||
));
|
||
|
||
// 创建 InstalledSkill 记录
|
||
let installed_skill = InstalledSkill {
|
||
id: skill.key.clone(),
|
||
name: skill.name.clone(),
|
||
description: if skill.description.is_empty() {
|
||
None
|
||
} else {
|
||
Some(skill.description.clone())
|
||
},
|
||
directory: install_name.clone(),
|
||
repo_owner: Some(skill.repo_owner.clone()),
|
||
repo_name: Some(skill.repo_name.clone()),
|
||
repo_branch: Some(repo_branch),
|
||
readme_url,
|
||
apps: SkillApps::only(current_app),
|
||
installed_at: chrono::Utc::now().timestamp(),
|
||
};
|
||
|
||
// 保存到数据库
|
||
db.save_skill(&installed_skill)?;
|
||
|
||
// 同步到当前应用目录
|
||
Self::sync_to_app_dir(&install_name, current_app)?;
|
||
|
||
log::info!(
|
||
"Skill {} 安装成功,已启用 {:?}",
|
||
installed_skill.name,
|
||
current_app
|
||
);
|
||
|
||
Ok(installed_skill)
|
||
}
|
||
|
||
/// 卸载 Skill
|
||
///
|
||
/// 流程:
|
||
/// 1. 从所有应用目录删除
|
||
/// 2. 从 SSOT 删除
|
||
/// 3. 从数据库删除
|
||
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<()> {
|
||
// 获取 skill 信息
|
||
let skill = db
|
||
.get_installed_skill(id)?
|
||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||
|
||
// 从所有应用目录删除
|
||
for app in AppType::all() {
|
||
let _ = Self::remove_from_app(&skill.directory, &app);
|
||
}
|
||
|
||
// 从 SSOT 删除
|
||
let ssot_dir = Self::get_ssot_dir()?;
|
||
let skill_path = ssot_dir.join(&skill.directory);
|
||
if skill_path.exists() {
|
||
fs::remove_dir_all(&skill_path)?;
|
||
}
|
||
|
||
// 从数据库删除
|
||
db.delete_skill(id)?;
|
||
|
||
log::info!("Skill {} 卸载成功", skill.name);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 切换应用启用状态
|
||
///
|
||
/// 启用:复制到应用目录
|
||
/// 禁用:从应用目录删除
|
||
pub fn toggle_app(db: &Arc<Database>, id: &str, app: &AppType, enabled: bool) -> Result<()> {
|
||
// 获取当前 skill
|
||
let mut skill = db
|
||
.get_installed_skill(id)?
|
||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||
|
||
// 更新状态
|
||
skill.apps.set_enabled_for(app, enabled);
|
||
|
||
// 同步文件
|
||
if enabled {
|
||
Self::sync_to_app_dir(&skill.directory, app)?;
|
||
} else {
|
||
Self::remove_from_app(&skill.directory, app)?;
|
||
}
|
||
|
||
// 更新数据库
|
||
db.update_skill_apps(id, &skill.apps)?;
|
||
|
||
log::info!("Skill {} 的 {:?} 状态已更新为 {}", skill.name, app, enabled);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 扫描未管理的 Skills
|
||
///
|
||
/// 扫描各应用目录,找出未被 CC Switch 管理的 Skills
|
||
pub fn scan_unmanaged(db: &Arc<Database>) -> Result<Vec<UnmanagedSkill>> {
|
||
let managed_skills = db.get_all_installed_skills()?;
|
||
let managed_dirs: HashSet<String> = managed_skills
|
||
.values()
|
||
.map(|s| s.directory.clone())
|
||
.collect();
|
||
|
||
// 收集所有待扫描的目录及其来源标签
|
||
let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
|
||
for app in AppType::all() {
|
||
if let Ok(d) = Self::get_app_skills_dir(&app) {
|
||
scan_sources.push((d, app.as_str().to_string()));
|
||
}
|
||
}
|
||
if let Some(agents_dir) = get_agents_skills_dir() {
|
||
scan_sources.push((agents_dir, "agents".to_string()));
|
||
}
|
||
if let Ok(ssot_dir) = Self::get_ssot_dir() {
|
||
scan_sources.push((ssot_dir, "cc-switch".to_string()));
|
||
}
|
||
|
||
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
||
|
||
for (scan_dir, label) in &scan_sources {
|
||
let entries = match fs::read_dir(scan_dir) {
|
||
Ok(e) => e,
|
||
Err(_) => continue,
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if !path.is_dir() {
|
||
continue;
|
||
}
|
||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||
if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {
|
||
continue;
|
||
}
|
||
|
||
let skill_md = path.join("SKILL.md");
|
||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||
|
||
unmanaged
|
||
.entry(dir_name.clone())
|
||
.and_modify(|s| s.found_in.push(label.clone()))
|
||
.or_insert(UnmanagedSkill {
|
||
directory: dir_name,
|
||
name,
|
||
description,
|
||
found_in: vec![label.clone()],
|
||
path: path.display().to_string(),
|
||
});
|
||
}
|
||
}
|
||
|
||
Ok(unmanaged.into_values().collect())
|
||
}
|
||
|
||
/// 从应用目录导入 Skills
|
||
///
|
||
/// 将未管理的 Skills 导入到 CC Switch 统一管理
|
||
pub fn import_from_apps(
|
||
db: &Arc<Database>,
|
||
directories: Vec<String>,
|
||
) -> Result<Vec<InstalledSkill>> {
|
||
let ssot_dir = Self::get_ssot_dir()?;
|
||
let agents_lock = parse_agents_lock();
|
||
let mut imported = Vec::new();
|
||
|
||
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||
save_repos_from_lock(db, &agents_lock, directories.iter().map(|s| s.as_str()));
|
||
|
||
// 收集所有候选搜索目录
|
||
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
|
||
for app in AppType::all() {
|
||
if let Ok(d) = Self::get_app_skills_dir(&app) {
|
||
search_sources.push((d, app.as_str().to_string()));
|
||
}
|
||
}
|
||
if let Some(agents_dir) = get_agents_skills_dir() {
|
||
search_sources.push((agents_dir, "agents".to_string()));
|
||
}
|
||
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
|
||
|
||
for dir_name in directories {
|
||
// 在所有候选目录中查找
|
||
let mut source_path: Option<PathBuf> = None;
|
||
let mut found_in: Vec<String> = Vec::new();
|
||
|
||
for (base, label) in &search_sources {
|
||
let skill_path = base.join(&dir_name);
|
||
if skill_path.exists() {
|
||
if source_path.is_none() {
|
||
source_path = Some(skill_path);
|
||
}
|
||
found_in.push(label.clone());
|
||
}
|
||
}
|
||
|
||
let source = match source_path {
|
||
Some(p) => p,
|
||
None => continue,
|
||
};
|
||
|
||
// 复制到 SSOT
|
||
let dest = ssot_dir.join(&dir_name);
|
||
if !dest.exists() {
|
||
Self::copy_dir_recursive(&source, &dest)?;
|
||
}
|
||
|
||
// 解析元数据
|
||
let skill_md = dest.join("SKILL.md");
|
||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||
|
||
// 构建启用状态
|
||
let apps = SkillApps::from_labels(&found_in);
|
||
|
||
// 从 lock 文件提取仓库信息
|
||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||
build_repo_info_from_lock(&agents_lock, &dir_name);
|
||
|
||
// 创建记录
|
||
let skill = InstalledSkill {
|
||
id,
|
||
name,
|
||
description,
|
||
directory: dir_name,
|
||
repo_owner,
|
||
repo_name,
|
||
repo_branch,
|
||
readme_url,
|
||
apps,
|
||
installed_at: chrono::Utc::now().timestamp(),
|
||
};
|
||
|
||
// 保存到数据库
|
||
db.save_skill(&skill)?;
|
||
imported.push(skill);
|
||
}
|
||
|
||
log::info!("成功导入 {} 个 Skills", imported.len());
|
||
|
||
Ok(imported)
|
||
}
|
||
|
||
// ========== 文件同步方法 ==========
|
||
|
||
/// 创建符号链接(跨平台)
|
||
///
|
||
/// - Unix: 使用 std::os::unix::fs::symlink
|
||
/// - Windows: 使用 std::os::windows::fs::symlink_dir
|
||
#[cfg(unix)]
|
||
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||
std::os::unix::fs::symlink(src, dest)
|
||
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||
std::os::windows::fs::symlink_dir(src, dest)
|
||
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||
}
|
||
|
||
/// 检查路径是否为符号链接
|
||
fn is_symlink(path: &Path) -> bool {
|
||
path.symlink_metadata()
|
||
.map(|m| m.file_type().is_symlink())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// 获取当前同步方式配置
|
||
fn get_sync_method() -> SyncMethod {
|
||
crate::settings::get_skill_sync_method()
|
||
}
|
||
|
||
/// 同步 Skill 到应用目录(使用 symlink 或 copy)
|
||
///
|
||
/// 根据配置和平台选择最佳同步方式:
|
||
/// - Auto: 优先尝试 symlink,失败时回退到 copy
|
||
/// - Symlink: 仅使用 symlink
|
||
/// - Copy: 仅使用文件复制
|
||
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
|
||
let ssot_dir = Self::get_ssot_dir()?;
|
||
let source = ssot_dir.join(directory);
|
||
|
||
if !source.exists() {
|
||
return Err(anyhow!("Skill 不存在于 SSOT: {directory}"));
|
||
}
|
||
|
||
let app_dir = Self::get_app_skills_dir(app)?;
|
||
fs::create_dir_all(&app_dir)?;
|
||
|
||
let dest = app_dir.join(directory);
|
||
|
||
// 如果已存在则先删除(无论是 symlink 还是真实目录)
|
||
if dest.exists() || Self::is_symlink(&dest) {
|
||
Self::remove_path(&dest)?;
|
||
}
|
||
|
||
let sync_method = Self::get_sync_method();
|
||
|
||
match sync_method {
|
||
SyncMethod::Auto => {
|
||
// 优先尝试 symlink
|
||
match Self::create_symlink(&source, &dest) {
|
||
Ok(()) => {
|
||
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||
return Ok(());
|
||
}
|
||
Err(err) => {
|
||
log::warn!(
|
||
"Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err:#}",
|
||
source.display(),
|
||
dest.display()
|
||
);
|
||
}
|
||
}
|
||
// Fallback 到 copy
|
||
Self::copy_dir_recursive(&source, &dest)?;
|
||
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||
}
|
||
SyncMethod::Symlink => {
|
||
Self::create_symlink(&source, &dest)?;
|
||
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||
}
|
||
SyncMethod::Copy => {
|
||
Self::copy_dir_recursive(&source, &dest)?;
|
||
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 复制 Skill 到应用目录(保留用于向后兼容)
|
||
#[deprecated(note = "请使用 sync_to_app_dir() 代替")]
|
||
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
||
Self::sync_to_app_dir(directory, app)
|
||
}
|
||
|
||
/// 删除路径(支持 symlink 和真实目录)
|
||
fn remove_path(path: &Path) -> Result<()> {
|
||
if Self::is_symlink(path) {
|
||
// 符号链接:仅删除链接本身,不影响源文件
|
||
#[cfg(unix)]
|
||
fs::remove_file(path)?;
|
||
#[cfg(windows)]
|
||
fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir
|
||
} else if path.is_dir() {
|
||
// 真实目录:递归删除
|
||
fs::remove_dir_all(path)?;
|
||
} else if path.exists() {
|
||
// 普通文件
|
||
fs::remove_file(path)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||
let app_dir = Self::get_app_skills_dir(app)?;
|
||
let skill_path = app_dir.join(directory);
|
||
|
||
if skill_path.exists() || Self::is_symlink(&skill_path) {
|
||
Self::remove_path(&skill_path)?;
|
||
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 同步所有已启用的 Skills 到指定应用
|
||
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
|
||
let skills = db.get_all_installed_skills()?;
|
||
|
||
for skill in skills.values() {
|
||
if skill.apps.is_enabled_for(app) {
|
||
Self::sync_to_app_dir(&skill.directory, app)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ========== 发现功能(保留原有逻辑)==========
|
||
|
||
/// 列出所有可发现的技能(从仓库获取)
|
||
pub async fn discover_available(
|
||
&self,
|
||
repos: Vec<SkillRepo>,
|
||
) -> Result<Vec<DiscoverableSkill>> {
|
||
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<DiscoverableSkill>>> =
|
||
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::deduplicate_discoverable_skills(&mut skills);
|
||
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||
|
||
Ok(skills)
|
||
}
|
||
|
||
/// 列出所有技能(兼容旧 API)
|
||
pub async fn list_skills(
|
||
&self,
|
||
repos: Vec<SkillRepo>,
|
||
db: &Arc<Database>,
|
||
) -> Result<Vec<Skill>> {
|
||
// 获取可发现的技能
|
||
let discoverable = self.discover_available(repos).await?;
|
||
|
||
// 获取已安装的技能
|
||
let installed = db.get_all_installed_skills()?;
|
||
let installed_dirs: HashSet<String> =
|
||
installed.values().map(|s| s.directory.clone()).collect();
|
||
|
||
// 转换为 Skill 格式
|
||
let mut skills: Vec<Skill> = discoverable
|
||
.into_iter()
|
||
.map(|d| {
|
||
let install_name = Path::new(&d.directory)
|
||
.file_name()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| d.directory.clone());
|
||
|
||
Skill {
|
||
key: d.key,
|
||
name: d.name,
|
||
description: d.description,
|
||
directory: d.directory,
|
||
readme_url: d.readme_url,
|
||
installed: installed_dirs.contains(&install_name),
|
||
repo_owner: Some(d.repo_owner),
|
||
repo_name: Some(d.repo_name),
|
||
repo_branch: Some(d.repo_branch),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// 添加本地已安装但不在仓库中的技能
|
||
for skill in installed.values() {
|
||
let already_in_list = skills.iter().any(|s| {
|
||
let s_install_name = Path::new(&s.directory)
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| s.directory.clone());
|
||
s_install_name == skill.directory
|
||
});
|
||
|
||
if !already_in_list {
|
||
skills.push(Skill {
|
||
key: skill.id.clone(),
|
||
name: skill.name.clone(),
|
||
description: skill.description.clone().unwrap_or_default(),
|
||
directory: skill.directory.clone(),
|
||
readme_url: skill.readme_url.clone(),
|
||
installed: true,
|
||
repo_owner: skill.repo_owner.clone(),
|
||
repo_name: skill.repo_name.clone(),
|
||
repo_branch: skill.repo_branch.clone(),
|
||
});
|
||
}
|
||
}
|
||
|
||
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<DiscoverableSkill>> {
|
||
let (temp_dir, resolved_branch) =
|
||
timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
|
||
.await
|
||
.map_err(|_| {
|
||
anyhow!(format_skill_error(
|
||
"DOWNLOAD_TIMEOUT",
|
||
&[
|
||
("owner", &repo.owner),
|
||
("name", &repo.name),
|
||
("timeout", "60")
|
||
],
|
||
Some("checkNetwork"),
|
||
))
|
||
})??;
|
||
|
||
let mut skills = Vec::new();
|
||
let scan_dir = temp_dir.clone();
|
||
let mut resolved_repo = repo.clone();
|
||
resolved_repo.branch = resolved_branch;
|
||
self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?;
|
||
|
||
let _ = fs::remove_dir_all(&temp_dir);
|
||
|
||
Ok(skills)
|
||
}
|
||
|
||
/// 递归扫描目录查找 SKILL.md
|
||
fn scan_dir_recursive(
|
||
&self,
|
||
current_dir: &Path,
|
||
base_dir: &Path,
|
||
repo: &SkillRepo,
|
||
skills: &mut Vec<DiscoverableSkill>,
|
||
) -> Result<()> {
|
||
let skill_md = current_dir.join("SKILL.md");
|
||
|
||
if skill_md.exists() {
|
||
let directory = if current_dir == base_dir {
|
||
repo.name.clone()
|
||
} else {
|
||
current_dir
|
||
.strip_prefix(base_dir)
|
||
.unwrap_or(current_dir)
|
||
.to_string_lossy()
|
||
.to_string()
|
||
};
|
||
|
||
let doc_path = skill_md
|
||
.strip_prefix(base_dir)
|
||
.unwrap_or(skill_md.as_path())
|
||
.to_string_lossy()
|
||
.replace('\\', "/");
|
||
|
||
if let Ok(skill) =
|
||
self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo)
|
||
{
|
||
skills.push(skill);
|
||
}
|
||
|
||
return Ok(());
|
||
}
|
||
|
||
for entry in fs::read_dir(current_dir)? {
|
||
let entry = entry?;
|
||
let path = entry.path();
|
||
|
||
if path.is_dir() {
|
||
self.scan_dir_recursive(&path, base_dir, repo, skills)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 从 SKILL.md 构建技能对象
|
||
fn build_skill_from_metadata(
|
||
&self,
|
||
skill_md: &Path,
|
||
directory: &str,
|
||
doc_path: &str,
|
||
repo: &SkillRepo,
|
||
) -> Result<DiscoverableSkill> {
|
||
let meta = self.parse_skill_metadata(skill_md)?;
|
||
|
||
Ok(DiscoverableSkill {
|
||
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||
name: meta.name.unwrap_or_else(|| directory.to_string()),
|
||
description: meta.description.unwrap_or_default(),
|
||
directory: directory.to_string(),
|
||
readme_url: Some(Self::build_skill_doc_url(
|
||
&repo.owner,
|
||
&repo.name,
|
||
&repo.branch,
|
||
doc_path,
|
||
)),
|
||
repo_owner: repo.owner.clone(),
|
||
repo_name: repo.name.clone(),
|
||
repo_branch: repo.branch.clone(),
|
||
})
|
||
}
|
||
|
||
/// 解析技能元数据
|
||
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
|
||
Self::parse_skill_metadata_static(path)
|
||
}
|
||
|
||
/// 静态方法:解析技能元数据
|
||
fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
|
||
let content = fs::read_to_string(path)?;
|
||
let content = content.trim_start_matches('\u{feff}');
|
||
|
||
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)
|
||
}
|
||
|
||
/// 从 SKILL.md 读取名称和描述,不存在则用目录名兜底
|
||
fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option<String>) {
|
||
if skill_md.exists() {
|
||
match Self::parse_skill_metadata_static(skill_md) {
|
||
Ok(meta) => (
|
||
meta.name.unwrap_or_else(|| fallback_name.to_string()),
|
||
meta.description,
|
||
),
|
||
Err(_) => (fallback_name.to_string(), None),
|
||
}
|
||
} else {
|
||
(fallback_name.to_string(), None)
|
||
}
|
||
}
|
||
|
||
/// 校验并规范化技能源路径(允许多级目录),拒绝路径穿越和绝对路径
|
||
fn sanitize_skill_source_path(raw: &str) -> Option<PathBuf> {
|
||
let trimmed = raw.trim();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut normalized = PathBuf::new();
|
||
let mut has_component = false;
|
||
|
||
for component in Path::new(trimmed).components() {
|
||
match component {
|
||
Component::Normal(name) => {
|
||
let segment = name.to_string_lossy().trim().to_string();
|
||
if segment.is_empty() || segment == "." || segment == ".." {
|
||
return None;
|
||
}
|
||
normalized.push(segment);
|
||
has_component = true;
|
||
}
|
||
Component::CurDir
|
||
| Component::ParentDir
|
||
| Component::RootDir
|
||
| Component::Prefix(_) => {
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
|
||
has_component.then_some(normalized)
|
||
}
|
||
|
||
/// 校验并规范化安装目录名(最终落盘目录名,仅单段)
|
||
fn sanitize_install_name(raw: &str) -> Option<String> {
|
||
let trimmed = raw.trim();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let path = Path::new(trimmed);
|
||
let mut components = path.components();
|
||
match (components.next(), components.next()) {
|
||
(Some(Component::Normal(name)), None) => {
|
||
let normalized = name.to_string_lossy().trim().to_string();
|
||
if normalized.is_empty()
|
||
|| normalized == "."
|
||
|| normalized == ".."
|
||
|| normalized.starts_with('.')
|
||
{
|
||
None
|
||
} else {
|
||
Some(normalized)
|
||
}
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||
let mut seen = HashMap::new();
|
||
skills.retain(|skill| {
|
||
// 使用完整 key(owner/repo:directory)作为唯一标识
|
||
// 这样不同仓库的同名 skill 会分开显示
|
||
let unique_key = skill.key.to_lowercase();
|
||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
||
e.insert(true);
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 下载仓库
|
||
async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> {
|
||
let temp_dir = tempfile::tempdir()?;
|
||
let temp_path = temp_dir.path().to_path_buf();
|
||
let _ = temp_dir.keep();
|
||
|
||
let mut branches = Vec::new();
|
||
if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
|
||
branches.push(repo.branch.as_str());
|
||
}
|
||
if !branches.contains(&"main") {
|
||
branches.push("main");
|
||
}
|
||
if !branches.contains(&"master") {
|
||
branches.push("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, branch.to_string()));
|
||
}
|
||
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<()> {
|
||
let client = crate::proxy::http_client::get();
|
||
let response = client.get(url).send().await?;
|
||
if !response.status().is_success() {
|
||
let status = response.status().as_u16().to_string();
|
||
return Err(anyhow::anyhow!(format_skill_error(
|
||
"DOWNLOAD_FAILED",
|
||
&[("status", &status)],
|
||
match status.as_str() {
|
||
"403" => Some("http403"),
|
||
"404" => Some("http404"),
|
||
"429" => Some("http429"),
|
||
_ => Some("checkNetwork"),
|
||
},
|
||
)));
|
||
}
|
||
|
||
let bytes = response.bytes().await?;
|
||
let cursor = std::io::Cursor::new(bytes);
|
||
let mut archive = zip::ZipArchive::new(cursor)?;
|
||
|
||
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!(format_skill_error(
|
||
"EMPTY_ARCHIVE",
|
||
&[],
|
||
Some("checkRepoUrl"),
|
||
)));
|
||
};
|
||
|
||
// 第一遍:解压普通文件和目录,收集 symlink 条目
|
||
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
|
||
|
||
for i in 0..archive.len() {
|
||
let mut file = archive.by_index(i)?;
|
||
let file_path = file.name().to_string();
|
||
|
||
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_symlink() {
|
||
// 读取 symlink 目标路径
|
||
let mut target = String::new();
|
||
std::io::Read::read_to_string(&mut file, &mut target)?;
|
||
symlinks.push((outpath, target.trim().to_string()));
|
||
} else 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)?;
|
||
}
|
||
}
|
||
|
||
// 第二遍:解析 symlink,将目标内容复制到 symlink 位置
|
||
Self::resolve_symlinks_in_dir(dest, &symlinks)?;
|
||
|
||
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(())
|
||
}
|
||
|
||
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
|
||
///
|
||
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
|
||
/// 此方法将 symlink 解析为实际文件/目录内容(而非创建真实 symlink),
|
||
/// 以确保跨平台兼容且 skill 内容自包含。
|
||
fn resolve_symlinks_in_dir(base_dir: &Path, symlinks: &[(PathBuf, String)]) -> Result<()> {
|
||
// 规范化 base_dir(macOS 上 /tmp → /private/tmp,需保持一致)
|
||
let canonical_base = base_dir
|
||
.canonicalize()
|
||
.unwrap_or_else(|_| base_dir.to_path_buf());
|
||
|
||
for (link_path, target) in symlinks {
|
||
// 计算 symlink 的父目录,然后拼接目标的相对路径
|
||
let parent = link_path.parent().unwrap_or(base_dir);
|
||
let resolved = parent.join(target);
|
||
|
||
// 规范化路径(解析 .. 等)
|
||
let resolved = match resolved.canonicalize() {
|
||
Ok(p) => p,
|
||
Err(_) => {
|
||
log::warn!(
|
||
"Symlink 目标不存在,跳过: {} -> {}",
|
||
link_path.display(),
|
||
target
|
||
);
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// 安全检查:确保目标在 base_dir 内(防止路径穿越)
|
||
if !resolved.starts_with(&canonical_base) {
|
||
log::warn!(
|
||
"Symlink 目标超出仓库范围,跳过: {} -> {}",
|
||
link_path.display(),
|
||
resolved.display()
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// 复制目标内容到 symlink 位置
|
||
if resolved.is_dir() {
|
||
Self::copy_dir_recursive(&resolved, link_path)?;
|
||
} else if resolved.is_file() {
|
||
if let Some(parent) = link_path.parent() {
|
||
fs::create_dir_all(parent)?;
|
||
}
|
||
fs::copy(&resolved, link_path)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ========== 从 ZIP 文件安装 ==========
|
||
|
||
/// 从本地 ZIP 文件安装 Skills
|
||
///
|
||
/// 流程:
|
||
/// 1. 解压 ZIP 到临时目录
|
||
/// 2. 扫描目录查找包含 SKILL.md 的技能
|
||
/// 3. 复制到 SSOT 并保存到数据库
|
||
/// 4. 同步到当前应用目录
|
||
pub fn install_from_zip(
|
||
db: &Arc<Database>,
|
||
zip_path: &Path,
|
||
current_app: &AppType,
|
||
) -> Result<Vec<InstalledSkill>> {
|
||
// 解压到临时目录
|
||
let temp_dir = Self::extract_local_zip(zip_path)?;
|
||
|
||
// 扫描所有包含 SKILL.md 的目录
|
||
let skill_dirs = Self::scan_skills_in_dir(&temp_dir)?;
|
||
|
||
if skill_dirs.is_empty() {
|
||
let _ = fs::remove_dir_all(&temp_dir);
|
||
return Err(anyhow!(format_skill_error(
|
||
"NO_SKILLS_IN_ZIP",
|
||
&[],
|
||
Some("checkZipContent"),
|
||
)));
|
||
}
|
||
|
||
let ssot_dir = Self::get_ssot_dir()?;
|
||
let mut installed = Vec::new();
|
||
let existing_skills = db.get_all_installed_skills()?;
|
||
let zip_stem = zip_path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.map(|s| s.to_string());
|
||
|
||
for skill_dir in skill_dirs {
|
||
// 解析元数据(提前解析,用于确定安装名)
|
||
let skill_md = skill_dir.join("SKILL.md");
|
||
let meta = if skill_md.exists() {
|
||
Self::parse_skill_metadata_static(&skill_md).ok()
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// 获取目录名称作为安装名
|
||
// 当 SKILL.md 在 ZIP 根目录时,skill_dir == temp_dir,
|
||
// file_name() 会返回临时目录名(如 .tmpDZKGpF),需要回退到其他来源
|
||
let install_name = {
|
||
let dir_name = skill_dir
|
||
.file_name()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_default();
|
||
|
||
if skill_dir == temp_dir || dir_name.is_empty() || dir_name.starts_with('.') {
|
||
// SKILL.md 在根目录:优先用元数据 name,否则用 ZIP 文件名
|
||
meta.as_ref()
|
||
.and_then(|m| m.name.as_deref())
|
||
.and_then(Self::sanitize_install_name)
|
||
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
|
||
} else {
|
||
Self::sanitize_install_name(&dir_name)
|
||
.or_else(|| {
|
||
meta.as_ref()
|
||
.and_then(|m| m.name.as_deref())
|
||
.and_then(Self::sanitize_install_name)
|
||
})
|
||
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
|
||
}
|
||
};
|
||
let install_name = match install_name {
|
||
Some(name) => name,
|
||
None => {
|
||
let _ = fs::remove_dir_all(&temp_dir);
|
||
return Err(anyhow!(format_skill_error(
|
||
"INVALID_SKILL_DIRECTORY",
|
||
&[("zip", &zip_path.display().to_string())],
|
||
Some("checkZipContent"),
|
||
)));
|
||
}
|
||
};
|
||
|
||
// 检查是否已有同名 directory 的 skill
|
||
let conflict = existing_skills
|
||
.values()
|
||
.find(|s| s.directory.eq_ignore_ascii_case(&install_name));
|
||
|
||
if let Some(existing) = conflict {
|
||
log::warn!(
|
||
"Skill directory '{}' already exists (from {}), skipping",
|
||
install_name,
|
||
existing.id
|
||
);
|
||
continue;
|
||
}
|
||
|
||
let (name, description) = match meta {
|
||
Some(m) => (
|
||
m.name.unwrap_or_else(|| install_name.clone()),
|
||
m.description,
|
||
),
|
||
None => (install_name.clone(), None),
|
||
};
|
||
|
||
// 复制到 SSOT
|
||
let dest = ssot_dir.join(&install_name);
|
||
if dest.exists() {
|
||
let _ = fs::remove_dir_all(&dest);
|
||
}
|
||
Self::copy_dir_recursive(&skill_dir, &dest)?;
|
||
|
||
// 创建 InstalledSkill 记录
|
||
let skill = InstalledSkill {
|
||
id: format!("local:{install_name}"),
|
||
name,
|
||
description,
|
||
directory: install_name.clone(),
|
||
repo_owner: None,
|
||
repo_name: None,
|
||
repo_branch: None,
|
||
readme_url: None,
|
||
apps: SkillApps::only(current_app),
|
||
installed_at: chrono::Utc::now().timestamp(),
|
||
};
|
||
|
||
// 保存到数据库
|
||
db.save_skill(&skill)?;
|
||
|
||
// 同步到当前应用目录
|
||
Self::sync_to_app_dir(&install_name, current_app)?;
|
||
|
||
log::info!(
|
||
"Skill {} installed from ZIP, enabled for {:?}",
|
||
skill.name,
|
||
current_app
|
||
);
|
||
installed.push(skill);
|
||
}
|
||
|
||
// 清理临时目录
|
||
let _ = fs::remove_dir_all(&temp_dir);
|
||
|
||
Ok(installed)
|
||
}
|
||
|
||
/// 解压本地 ZIP 文件到临时目录
|
||
fn extract_local_zip(zip_path: &Path) -> Result<PathBuf> {
|
||
let file = fs::File::open(zip_path)
|
||
.with_context(|| format!("Failed to open ZIP file: {}", zip_path.display()))?;
|
||
|
||
let mut archive = zip::ZipArchive::new(file)
|
||
.with_context(|| format!("Failed to read ZIP file: {}", zip_path.display()))?;
|
||
|
||
if archive.is_empty() {
|
||
return Err(anyhow!(format_skill_error(
|
||
"EMPTY_ARCHIVE",
|
||
&[],
|
||
Some("checkZipContent"),
|
||
)));
|
||
}
|
||
|
||
let temp_dir = tempfile::tempdir()?;
|
||
let temp_path = temp_dir.path().to_path_buf();
|
||
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
|
||
|
||
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
|
||
|
||
for i in 0..archive.len() {
|
||
let mut file = archive.by_index(i)?;
|
||
let file_path = match file.enclosed_name() {
|
||
Some(path) => path.to_owned(),
|
||
None => continue,
|
||
};
|
||
|
||
let outpath = temp_path.join(&file_path);
|
||
|
||
if file.is_symlink() {
|
||
let mut target = String::new();
|
||
std::io::Read::read_to_string(&mut file, &mut target)?;
|
||
symlinks.push((outpath, target.trim().to_string()));
|
||
} else 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)?;
|
||
}
|
||
}
|
||
|
||
// 解析 symlink
|
||
Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;
|
||
|
||
Ok(temp_path)
|
||
}
|
||
|
||
/// 递归扫描目录查找包含 SKILL.md 的技能目录
|
||
fn scan_skills_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {
|
||
let mut skill_dirs = Vec::new();
|
||
Self::scan_skills_recursive(dir, &mut skill_dirs)?;
|
||
Ok(skill_dirs)
|
||
}
|
||
|
||
/// 递归扫描辅助函数
|
||
fn scan_skills_recursive(current: &Path, results: &mut Vec<PathBuf>) -> Result<()> {
|
||
// 检查当前目录是否包含 SKILL.md
|
||
let skill_md = current.join("SKILL.md");
|
||
if skill_md.exists() {
|
||
results.push(current.to_path_buf());
|
||
// 找到后不再递归子目录(一个 skill 目录)
|
||
return Ok(());
|
||
}
|
||
|
||
// 递归子目录
|
||
if let Ok(entries) = fs::read_dir(current) {
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
// 跳过隐藏目录
|
||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||
if dir_name.starts_with('.') {
|
||
continue;
|
||
}
|
||
Self::scan_skills_recursive(&path, results)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
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(())
|
||
}
|
||
}
|
||
|
||
// ========== 迁移支持 ==========
|
||
|
||
/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL
|
||
///
|
||
/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)
|
||
fn build_repo_info_from_lock(
|
||
lock: &HashMap<String, LockRepoInfo>,
|
||
dir_name: &str,
|
||
) -> (
|
||
String,
|
||
Option<String>,
|
||
Option<String>,
|
||
Option<String>,
|
||
Option<String>,
|
||
) {
|
||
match lock.get(dir_name) {
|
||
Some(info) => {
|
||
let branch = info.branch.clone();
|
||
let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string());
|
||
// 优先使用 lock 文件中的 skillPath,否则回退到 dir_name/SKILL.md
|
||
let fallback = format!("{dir_name}/SKILL.md");
|
||
let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);
|
||
let url = Some(SkillService::build_skill_doc_url(
|
||
&info.owner,
|
||
&info.repo,
|
||
&url_branch,
|
||
doc_path,
|
||
));
|
||
(
|
||
format!("{}/{}:{dir_name}", info.owner, info.repo),
|
||
Some(info.owner.clone()),
|
||
Some(info.repo.clone()),
|
||
branch,
|
||
url,
|
||
)
|
||
}
|
||
None => (format!("local:{dir_name}"), None, None, None, None),
|
||
}
|
||
}
|
||
|
||
/// 将 lock 文件中发现的仓库保存到 skill_repos(去重)
|
||
fn save_repos_from_lock(
|
||
db: &Arc<Database>,
|
||
lock: &HashMap<String, LockRepoInfo>,
|
||
directories: impl Iterator<Item = impl AsRef<str>>,
|
||
) {
|
||
let existing_repos: HashSet<(String, String)> = db
|
||
.get_skill_repos()
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.map(|r| (r.owner, r.name))
|
||
.collect();
|
||
let mut added = HashSet::new();
|
||
|
||
for dir_name in directories {
|
||
if let Some(info) = lock.get(dir_name.as_ref()) {
|
||
let key = (info.owner.clone(), info.repo.clone());
|
||
if !existing_repos.contains(&key) && added.insert(key) {
|
||
let skill_repo = SkillRepo {
|
||
owner: info.owner.clone(),
|
||
name: info.repo.clone(),
|
||
// 未知分支时使用 HEAD 语义,后续下载会回退到 main/master。
|
||
branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
|
||
enabled: true,
|
||
};
|
||
if let Err(e) = db.save_skill_repo(&skill_repo) {
|
||
log::warn!("保存 skill 仓库 {}/{} 失败: {}", info.owner, info.repo, e);
|
||
} else {
|
||
log::info!(
|
||
"从 agents lock 文件发现并添加仓库: {}/{} ({})",
|
||
info.owner,
|
||
info.repo,
|
||
skill_repo.branch
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 首次启动迁移:扫描应用目录,重建数据库
|
||
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||
let ssot_dir = SkillService::get_ssot_dir()?;
|
||
let agents_lock = parse_agents_lock();
|
||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||
|
||
// 扫描各应用目录
|
||
for app in AppType::all() {
|
||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||
Ok(d) => d,
|
||
Err(_) => continue,
|
||
};
|
||
|
||
let entries = match fs::read_dir(&app_dir) {
|
||
Ok(e) => e,
|
||
Err(_) => continue,
|
||
};
|
||
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if !path.is_dir() {
|
||
continue;
|
||
}
|
||
|
||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||
if dir_name.starts_with('.') {
|
||
continue;
|
||
}
|
||
|
||
// 复制到 SSOT(如果不存在)
|
||
let ssot_path = ssot_dir.join(&dir_name);
|
||
if !ssot_path.exists() {
|
||
SkillService::copy_dir_recursive(&path, &ssot_path)?;
|
||
}
|
||
|
||
discovered
|
||
.entry(dir_name)
|
||
.or_default()
|
||
.set_enabled_for(&app, true);
|
||
}
|
||
}
|
||
|
||
// 重建数据库
|
||
db.clear_skills()?;
|
||
|
||
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||
save_repos_from_lock(db, &agents_lock, discovered.keys());
|
||
|
||
let mut count = 0;
|
||
for (directory, apps) in discovered {
|
||
let ssot_path = ssot_dir.join(&directory);
|
||
let skill_md = ssot_path.join("SKILL.md");
|
||
|
||
let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);
|
||
|
||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||
build_repo_info_from_lock(&agents_lock, &directory);
|
||
|
||
let skill = InstalledSkill {
|
||
id,
|
||
name,
|
||
description,
|
||
directory,
|
||
repo_owner,
|
||
repo_name,
|
||
repo_branch,
|
||
readme_url,
|
||
apps,
|
||
installed_at: chrono::Utc::now().timestamp(),
|
||
};
|
||
|
||
db.save_skill(&skill)?;
|
||
count += 1;
|
||
}
|
||
|
||
log::info!("Skills 迁移完成,共 {count} 个");
|
||
|
||
Ok(count)
|
||
}
|