From 508aa6070cea26fb193916db7f5c3eff0285e4d1 Mon Sep 17 00:00:00 2001
From: Dex Miller
Date: Sun, 15 Feb 2026 20:57:14 +0800
Subject: [PATCH 1/9] Fix/skill zip symlink resolution (#1040)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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
---
src-tauri/src/app_config.rs | 16 +
src-tauri/src/services/skill.rs | 702 ++++++++++++++-----
src/components/skills/SkillsPage.tsx | 12 +-
src/components/skills/UnifiedSkillsPanel.tsx | 8 +-
src/i18n/locales/en.json | 2 +-
src/i18n/locales/ja.json | 2 +-
src/i18n/locales/zh.json | 2 +-
src/lib/api/skills.ts | 1 +
8 files changed, 574 insertions(+), 171 deletions(-)
diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs
index cf76e1fc8..18e3fbec8 100644
--- a/src-tauri/src/app_config.rs
+++ b/src-tauri/src/app_config.rs
@@ -129,6 +129,20 @@ impl SkillApps {
apps.set_enabled_for(app, true);
apps
}
+
+ /// 从来源标签列表构建启用状态
+ ///
+ /// 标签与 AppType::as_str() 一致时启用对应应用,
+ /// 其他标签(如 "agents", "cc-switch")忽略。
+ pub fn from_labels(labels: &[String]) -> Self {
+ let mut apps = Self::default();
+ for label in labels {
+ if let Ok(app) = label.parse::() {
+ apps.set_enabled_for(&app, true);
+ }
+ }
+ apps
+ }
}
/// 已安装的 Skill(v3.10.0+ 统一结构)
@@ -175,6 +189,8 @@ pub struct UnmanagedSkill {
pub description: Option,
/// 在哪些应用目录中发现(如 ["claude", "codex"])
pub found_in: Vec,
+ /// 发现路径(首个匹配的完整路径)
+ pub path: String,
}
/// MCP 服务器定义(v3.7.0 统一结构)
diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs
index f02136075..ea69a8091 100644
--- a/src-tauri/src/services/skill.rs
+++ b/src-tauri/src/services/skill.rs
@@ -10,7 +10,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
-use std::path::{Path, PathBuf};
+use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use tokio::time::timeout;
@@ -159,6 +159,154 @@ pub struct SkillMetadata {
pub description: Option,
}
+// ========== ~/.agents/ lock 文件解析 ==========
+
+/// `~/.agents/.skill-lock.json` 文件结构
+#[derive(Deserialize)]
+struct AgentsLockFile {
+ skills: HashMap,
+}
+
+/// lock 文件中单个 skill 的信息
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct AgentsLockSkill {
+ source: Option,
+ source_type: Option,
+ source_url: Option,
+ skill_path: Option,
+ branch: Option,
+ source_branch: Option,
+}
+
+#[derive(Debug, Clone)]
+struct LockRepoInfo {
+ owner: String,
+ repo: String,
+ skill_path: Option,
+ branch: Option,
+}
+
+fn normalize_optional_branch(branch: Option) -> Option {
+ 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 {
+ let source_url = source_url?;
+ let source_url = source_url.trim();
+ if source_url.is_empty() {
+ return None;
+ }
+
+ // 支持 https://github.com/owner/repo/tree//...
+ 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 {
+ 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 {
+ 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 = 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;
@@ -275,11 +423,25 @@ impl SkillService {
) -> Result {
let ssot_dir = Self::get_ssot_dir()?;
- // 使用目录最后一段作为安装名
- let install_name = Path::new(&skill.directory)
+ // 允许多级目录(如 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()
- .map(|s| s.to_string_lossy().to_string())
- .unwrap_or_else(|| skill.directory.clone());
+ .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()?;
@@ -358,7 +520,7 @@ impl SkillService {
repo_branch = used_branch;
// 复制到 SSOT
- let source = temp_dir.join(&skill.directory);
+ let source = temp_dir.join(&source_rel);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
@@ -368,7 +530,24 @@ impl SkillService {
)));
}
- Self::copy_dir_recursive(&source, &dest)?;
+ 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 与真实分支不一致。
@@ -449,12 +628,7 @@ impl SkillService {
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除
- for app in [
- AppType::Claude,
- AppType::Codex,
- AppType::Gemini,
- AppType::OpenCode,
- ] {
+ for app in AppType::all() {
let _ = Self::remove_from_app(&skill.directory, &app);
}
@@ -511,74 +685,49 @@ impl SkillService {
.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 = HashMap::new();
- for app in [
- AppType::Claude,
- AppType::Codex,
- AppType::Gemini,
- AppType::OpenCode,
- ] {
- let app_dir = match Self::get_app_skills_dir(&app) {
- Ok(d) => d,
+ for (scan_dir, label) in &scan_sources {
+ let entries = match fs::read_dir(scan_dir) {
+ Ok(e) => e,
Err(_) => continue,
};
-
- if !app_dir.exists() {
- continue;
- }
-
- for entry in fs::read_dir(&app_dir)? {
- let entry = entry?;
+ 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();
-
- // 跳过隐藏目录(以 . 开头,如 .system)
- if dir_name.starts_with('.') {
+ if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {
continue;
}
- // 跳过已管理的
- if managed_dirs.contains(&dir_name) {
- continue;
- }
-
- // 检查是否有 SKILL.md
let skill_md = path.join("SKILL.md");
- let (name, description) = if skill_md.exists() {
- match Self::parse_skill_metadata_static(&skill_md) {
- Ok(meta) => (
- meta.name.unwrap_or_else(|| dir_name.clone()),
- meta.description,
- ),
- Err(_) => (dir_name.clone(), None),
- }
- } else {
- (dir_name.clone(), None)
- };
-
- // 添加或更新
- let app_str = match app {
- AppType::Claude => "claude",
- AppType::Codex => "codex",
- AppType::Gemini => "gemini",
- AppType::OpenCode => "opencode",
- AppType::OpenClaw => "openclaw",
- };
+ let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
unmanaged
.entry(dir_name.clone())
- .and_modify(|s| s.found_in.push(app_str.to_string()))
+ .and_modify(|s| s.found_in.push(label.clone()))
.or_insert(UnmanagedSkill {
directory: dir_name,
name,
description,
- found_in: vec![app_str.to_string()],
+ found_in: vec![label.clone()],
+ path: path.display().to_string(),
});
}
}
@@ -594,34 +743,36 @@ impl SkillService {
directories: Vec,
) -> Result> {
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 = None;
let mut found_in: Vec = Vec::new();
- for app in [
- AppType::Claude,
- AppType::Codex,
- AppType::Gemini,
- AppType::OpenCode,
- ] {
- if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
- let skill_path = app_dir.join(&dir_name);
- if skill_path.exists() {
- if source_path.is_none() {
- source_path = Some(skill_path);
- }
- let app_str = match app {
- AppType::Claude => "claude",
- AppType::Codex => "codex",
- AppType::Gemini => "gemini",
- AppType::OpenCode => "opencode",
- AppType::OpenClaw => "openclaw",
- };
- found_in.push(app_str.to_string());
+ 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());
}
}
@@ -638,40 +789,25 @@ impl SkillService {
// 解析元数据
let skill_md = dest.join("SKILL.md");
- let (name, description) = if skill_md.exists() {
- match Self::parse_skill_metadata_static(&skill_md) {
- Ok(meta) => (
- meta.name.unwrap_or_else(|| dir_name.clone()),
- meta.description,
- ),
- Err(_) => (dir_name.clone(), None),
- }
- } else {
- (dir_name.clone(), None)
- };
+ let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
// 构建启用状态
- let mut apps = SkillApps::default();
- for app_str in &found_in {
- match app_str.as_str() {
- "claude" => apps.claude = true,
- "codex" => apps.codex = true,
- "gemini" => apps.gemini = true,
- "opencode" => apps.opencode = true,
- _ => {}
- }
- }
+ 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: format!("local:{dir_name}"),
+ id,
name,
description,
directory: dir_name,
- repo_owner: None,
- repo_name: None,
- repo_branch: None,
- readme_url: None,
+ repo_owner,
+ repo_name,
+ repo_branch,
+ readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
@@ -1055,6 +1191,79 @@ impl SkillService {
Ok(meta)
}
+ /// 从 SKILL.md 读取名称和描述,不存在则用目录名兜底
+ fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option) {
+ 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 {
+ 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 {
+ 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) {
let mut seen = HashMap::new();
@@ -1078,7 +1287,7 @@ impl SkillService {
let _ = temp_dir.keep();
let mut branches = Vec::new();
- if !repo.branch.is_empty() {
+ if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
branches.push(repo.branch.as_str());
}
if !branches.contains(&"main") {
@@ -1143,9 +1352,12 @@ impl SkillService {
)));
};
+ // 第一遍:解压普通文件和目录,收集 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();
+ let file_path = file.name().to_string();
let relative_path =
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
@@ -1160,7 +1372,12 @@ impl SkillService {
let outpath = dest.join(relative_path);
- if file.is_dir() {
+ 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() {
@@ -1171,6 +1388,9 @@ impl SkillService {
}
}
+ // 第二遍:解析 symlink,将目标内容复制到 symlink 位置
+ Self::resolve_symlinks_in_dir(dest, &symlinks)?;
+
Ok(())
}
@@ -1193,6 +1413,58 @@ impl SkillService {
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
@@ -1225,13 +1497,56 @@ impl SkillService {
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
+ };
+
// 获取目录名称作为安装名
- let install_name = skill_dir
- .file_name()
- .map(|s| s.to_string_lossy().to_string())
- .unwrap_or_else(|| "unknown".to_string());
+ // 当 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
@@ -1247,18 +1562,12 @@ impl SkillService {
continue;
}
- // 解析元数据
- let skill_md = skill_dir.join("SKILL.md");
- let (name, description) = if skill_md.exists() {
- match Self::parse_skill_metadata_static(&skill_md) {
- Ok(meta) => (
- meta.name.unwrap_or_else(|| install_name.clone()),
- meta.description,
- ),
- Err(_) => (install_name.clone(), None),
- }
- } else {
- (install_name.clone(), None)
+ let (name, description) = match meta {
+ Some(m) => (
+ m.name.unwrap_or_else(|| install_name.clone()),
+ m.description,
+ ),
+ None => (install_name.clone(), None),
};
// 复制到 SSOT
@@ -1322,6 +1631,8 @@ impl SkillService {
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() {
@@ -1331,7 +1642,11 @@ impl SkillService {
let outpath = temp_path.join(&file_path);
- if file.is_dir() {
+ 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() {
@@ -1342,6 +1657,9 @@ impl SkillService {
}
}
+ // 解析 symlink
+ Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;
+
Ok(temp_path)
}
@@ -1414,38 +1732,109 @@ impl SkillService {
// ========== 迁移支持 ==========
+/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL
+///
+/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)
+fn build_repo_info_from_lock(
+ lock: &HashMap,
+ dir_name: &str,
+) -> (
+ String,
+ Option,
+ Option,
+ Option,
+ Option,
+) {
+ 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,
+ lock: &HashMap,
+ directories: impl Iterator- >,
+) {
+ 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) -> Result {
let ssot_dir = SkillService::get_ssot_dir()?;
+ let agents_lock = parse_agents_lock();
let mut discovered: HashMap = HashMap::new();
// 扫描各应用目录
- for app in [
- AppType::Claude,
- AppType::Codex,
- AppType::Gemini,
- AppType::OpenCode,
- ] {
+ for app in AppType::all() {
let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
};
- if !app_dir.exists() {
- continue;
- }
+ let entries = match fs::read_dir(&app_dir) {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
- for entry in fs::read_dir(&app_dir)? {
- let entry = entry?;
+ 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();
-
- // 跳过隐藏目录(以 . 开头,如 .system)
if dir_name.starts_with('.') {
continue;
}
@@ -1456,7 +1845,6 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result {
SkillService::copy_dir_recursive(&path, &ssot_path)?;
}
- // 记录启用状态
discovered
.entry(dir_name)
.or_default()
@@ -1467,32 +1855,28 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result {
// 重建数据库
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) = if skill_md.exists() {
- match SkillService::parse_skill_metadata_static(&skill_md) {
- Ok(meta) => (
- meta.name.unwrap_or_else(|| directory.clone()),
- meta.description,
- ),
- Err(_) => (directory.clone(), None),
- }
- } else {
- (directory.clone(), None)
- };
+ 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: format!("local:{directory}"),
+ id,
name,
description,
directory,
- repo_owner: None,
- repo_name: None,
- repo_branch: None,
- readme_url: None,
+ repo_owner,
+ repo_name,
+ repo_branch,
+ readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx
index 1968d7793..3fa2c3f5e 100644
--- a/src/components/skills/SkillsPage.tsx
+++ b/src/components/skills/SkillsPage.tsx
@@ -213,14 +213,12 @@ export const SkillsPage = forwardRef(
const query = searchQuery.toLowerCase();
return byStatus.filter((skill) => {
const name = skill.name?.toLowerCase() || "";
- const description = skill.description?.toLowerCase() || "";
- const directory = skill.directory?.toLowerCase() || "";
+ const repo =
+ skill.repoOwner && skill.repoName
+ ? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()
+ : "";
- return (
- name.includes(query) ||
- description.includes(query) ||
- directory.includes(query)
- );
+ return name.includes(query) || repo.includes(query);
});
}, [skills, searchQuery, filterRepo, filterStatus]);
diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx
index aa647c70d..17e23a86b 100644
--- a/src/components/skills/UnifiedSkillsPanel.tsx
+++ b/src/components/skills/UnifiedSkillsPanel.tsx
@@ -306,6 +306,7 @@ interface ImportSkillsDialogProps {
name: string;
description?: string;
foundIn: string[];
+ path: string;
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
@@ -362,8 +363,11 @@ const ImportSkillsDialog: React.FC = ({
{skill.description}
)}
-
- {t("skills.foundIn")}: {skill.foundIn.join(", ")}
+
+ {skill.path}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index c3bebbd16..69acd81da 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1322,7 +1322,7 @@
"skillCount": "{{count}} skills detected"
},
"search": "Search Skills",
- "searchPlaceholder": "Search skill name or description...",
+ "searchPlaceholder": "Search skill name or repo...",
"filter": {
"placeholder": "Filter by status",
"all": "All",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 5196bf4e5..4b37c5010 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -1320,7 +1320,7 @@
"skillCount": "{{count}} 件のスキルを検出"
},
"search": "スキルを検索",
- "searchPlaceholder": "スキル名または説明で検索...",
+ "searchPlaceholder": "スキル名またはリポジトリで検索...",
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index cca58482b..f18795aba 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -1322,7 +1322,7 @@
"skillCount": "识别到 {{count}} 个技能"
},
"search": "搜索技能",
- "searchPlaceholder": "搜索技能名称或描述...",
+ "searchPlaceholder": "搜索技能名称或仓库名称...",
"filter": {
"placeholder": "状态筛选",
"all": "全部",
diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts
index 673cf7a34..151090773 100644
--- a/src/lib/api/skills.ts
+++ b/src/lib/api/skills.ts
@@ -45,6 +45,7 @@ export interface UnmanagedSkill {
name: string;
description?: string;
foundIn: string[];
+ path: string;
}
/** 技能对象(兼容旧 API) */
From 20f62bf4f81ee651018458b1f89e6433af880c59 Mon Sep 17 00:00:00 2001
From: SaladDay <92240037+SaladDay@users.noreply.github.com>
Date: Sun, 15 Feb 2026 20:58:17 +0800
Subject: [PATCH 2/9] =?UTF-8?q?feat(webdav):=20follow-up=20=E8=A1=A5?=
=?UTF-8?q?=E9=BD=90=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=A4=A7?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E9=98=B2=E6=8A=A4=20(#1043)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(webdav): add robust auto sync with failure feedback
(cherry picked from commit bb6760124a62a964b36902c004e173534910728f)
* fix(webdav): enforce bounded download and extraction size
(cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79)
* fix(webdav): only show auto-sync callout for auto-source errors
* refactor(webdav): remove services->commands auto-sync dependency
---
src-tauri/Cargo.toml | 2 +-
src-tauri/src/commands/webdav_sync.rs | 30 +-
src-tauri/src/database/mod.rs | 15 +-
src-tauri/src/lib.rs | 4 +
src-tauri/src/services/mod.rs | 1 +
src-tauri/src/services/webdav.rs | 125 +++++---
src-tauri/src/services/webdav_auto_sync.rs | 277 ++++++++++++++++++
src-tauri/src/services/webdav_sync.rs | 119 ++++++--
src-tauri/src/services/webdav_sync/archive.rs | 94 +++++-
src-tauri/src/settings.rs | 5 +
src/App.tsx | 50 ++++
src/components/settings/WebdavSyncSection.tsx | 45 +++
src/i18n/locales/en.json | 5 +
src/i18n/locales/ja.json | 5 +
src/i18n/locales/zh.json | 5 +
src/lib/schemas/settings.ts | 2 +
src/types.ts | 2 +
tests/components/WebdavSyncSection.test.tsx | 74 +++++
tests/integration/App.test.tsx | 21 ++
19 files changed, 777 insertions(+), 104 deletions(-)
create mode 100644 src-tauri/src/services/webdav_auto_sync.rs
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 9547eb342..e3dd7f59a 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -57,7 +57,7 @@ url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
-rusqlite = { version = "0.31", features = ["bundled", "backup"] }
+rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
diff --git a/src-tauri/src/commands/webdav_sync.rs b/src-tauri/src/commands/webdav_sync.rs
index e1bbd70d7..31879d13b 100644
--- a/src-tauri/src/commands/webdav_sync.rs
+++ b/src-tauri/src/commands/webdav_sync.rs
@@ -1,8 +1,6 @@
#![allow(non_snake_case)]
-use serde_json::{Value, json};
-use std::future::Future;
-use std::sync::OnceLock;
+use serde_json::{json, Value};
use tauri::State;
use crate::commands::sync_support::{
@@ -13,8 +11,9 @@ use crate::services::webdav_sync as webdav_sync_service;
use crate::settings::{self, WebDavSyncSettings};
use crate::store::AppState;
-fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {
+fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError, source: &str) {
settings.status.last_error = Some(error.to_string());
+ settings.status.last_error_source = Some(source.to_string());
let _ = settings::update_webdav_sync_status(settings.status.clone());
}
@@ -57,20 +56,16 @@ fn resolve_password_for_request(
incoming
}
+#[cfg(test)]
fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {
- static LOCK: OnceLock> = OnceLock::new();
- LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
+ webdav_sync_service::sync_mutex()
}
async fn run_with_webdav_lock(operation: Fut) -> Result
where
- Fut: Future
)}
+ {showAutoSyncError && (
+
+
+ {t("settings.webdavSync.autoSyncLastErrorTitle")}
+
+
{lastError}
+
+ {t("settings.webdavSync.autoSyncLastErrorHint")}
+
+
+ )}
{/* Config buttons + save status */}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 69acd81da..38dd8e961 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -283,6 +283,8 @@
"passwordPlaceholder": "App password",
"remoteRoot": "Remote Root Directory",
"profile": "Sync Profile Name",
+ "autoSync": "Auto Sync",
+ "autoSyncHint": "When enabled, each database change triggers an automatic WebDAV upload.",
"test": "Test Connection",
"testing": "Testing...",
"testSuccess": "Connection successful",
@@ -294,11 +296,14 @@
"uploading": "Uploading...",
"uploadSuccess": "Uploaded to WebDAV",
"uploadFailed": "Upload failed: {{error}}",
+ "autoSyncFailedToast": "Auto sync failed: {{error}}",
"download": "Download from Cloud",
"downloading": "Downloading...",
"downloadSuccess": "Downloaded and restored from WebDAV",
"downloadFailed": "Download failed: {{error}}",
"lastSync": "Last sync: {{time}}",
+ "autoSyncLastErrorTitle": "Last auto sync failed",
+ "autoSyncLastErrorHint": "Please check network or WebDAV settings. Auto sync will retry on future changes.",
"missingUrl": "Please enter the WebDAV server URL",
"presets": {
"label": "Provider",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 4b37c5010..1e185b6eb 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -283,6 +283,8 @@
"passwordPlaceholder": "アプリパスワード",
"remoteRoot": "リモートルートディレクトリ",
"profile": "同期プロファイル名",
+ "autoSync": "自動同期",
+ "autoSyncHint": "有効にすると、データベース変更のたびに WebDAV へ自動アップロードします。",
"test": "接続テスト",
"testing": "テスト中...",
"testSuccess": "接続成功",
@@ -294,11 +296,14 @@
"uploading": "アップロード中...",
"uploadSuccess": "WebDAV にアップロードしました",
"uploadFailed": "アップロードに失敗しました:{{error}}",
+ "autoSyncFailedToast": "自動同期に失敗しました:{{error}}",
"download": "クラウドからダウンロード",
"downloading": "ダウンロード中...",
"downloadSuccess": "WebDAV からダウンロード・復元しました",
"downloadFailed": "ダウンロードに失敗しました:{{error}}",
"lastSync": "前回の同期:{{time}}",
+ "autoSyncLastErrorTitle": "前回の自動同期に失敗しました",
+ "autoSyncLastErrorHint": "ネットワークまたは WebDAV 設定を確認してください。次回の変更時に自動再試行されます。",
"missingUrl": "WebDAV サーバー URL を入力してください",
"presets": {
"label": "サービス",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index f18795aba..842856f48 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -283,6 +283,8 @@
"passwordPlaceholder": "应用密码(坚果云请使用「第三方应用密码」)",
"remoteRoot": "远程根目录",
"profile": "同步配置名",
+ "autoSync": "自动同步",
+ "autoSyncHint": "开启后每次数据库变更都会自动上传到 WebDAV。",
"test": "测试连接",
"testing": "测试中...",
"testSuccess": "连接成功",
@@ -294,11 +296,14 @@
"uploading": "上传中...",
"uploadSuccess": "已上传到 WebDAV",
"uploadFailed": "上传失败:{{error}}",
+ "autoSyncFailedToast": "自动同步失败:{{error}}",
"download": "从云端下载",
"downloading": "下载中...",
"downloadSuccess": "已从 WebDAV 下载并恢复",
"downloadFailed": "下载失败:{{error}}",
"lastSync": "上次同步:{{time}}",
+ "autoSyncLastErrorTitle": "上次自动同步失败",
+ "autoSyncLastErrorHint": "请检查网络或 WebDAV 配置,系统会在后续变更时继续自动重试。",
"missingUrl": "请填写 WebDAV 服务器地址",
"presets": {
"label": "服务商",
diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts
index d226ef34c..87436c0d6 100644
--- a/src/lib/schemas/settings.ts
+++ b/src/lib/schemas/settings.ts
@@ -33,6 +33,7 @@ export const settingsSchema = z.object({
webdavSync: z
.object({
enabled: z.boolean().optional(),
+ autoSync: z.boolean().optional(),
baseUrl: z.string().trim().optional().or(z.literal("")),
username: z.string().trim().optional().or(z.literal("")),
password: z.string().optional(),
@@ -42,6 +43,7 @@ export const settingsSchema = z.object({
.object({
lastSyncAt: z.number().nullable().optional(),
lastError: z.string().nullable().optional(),
+ lastErrorSource: z.string().nullable().optional(),
lastRemoteEtag: z.string().nullable().optional(),
lastLocalManifestHash: z.string().nullable().optional(),
lastRemoteManifestHash: z.string().nullable().optional(),
diff --git a/src/types.ts b/src/types.ts
index d42b86ee4..ecbd17a74 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -167,6 +167,7 @@ export interface VisibleApps {
export interface WebDavSyncStatus {
lastSyncAt?: number | null;
lastError?: string | null;
+ lastErrorSource?: string | null;
lastRemoteEtag?: string | null;
lastLocalManifestHash?: string | null;
lastRemoteManifestHash?: string | null;
@@ -175,6 +176,7 @@ export interface WebDavSyncStatus {
// WebDAV v2 同步配置
export interface WebDavSyncSettings {
enabled?: boolean;
+ autoSync?: boolean;
baseUrl?: string;
username?: string;
password?: string;
diff --git a/tests/components/WebdavSyncSection.test.tsx b/tests/components/WebdavSyncSection.test.tsx
index 27a487a7b..ddffd51ed 100644
--- a/tests/components/WebdavSyncSection.test.tsx
+++ b/tests/components/WebdavSyncSection.test.tsx
@@ -34,6 +34,17 @@ vi.mock("@/components/ui/input", () => ({
Input: (props: any) =>
,
}));
+vi.mock("@/components/ui/switch", () => ({
+ Switch: ({ checked, onCheckedChange, ...props }: any) => (
+