From 74dcc0bb3ea099bd8825cd7409ebec6a979315f6 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 14 Feb 2026 14:14:40 +0800 Subject: [PATCH] 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 --- src-tauri/src/app_config.rs | 2 + src-tauri/src/services/skill.rs | 149 ++++++++++++++++++- src/components/skills/SkillsPage.tsx | 7 +- src/components/skills/UnifiedSkillsPanel.tsx | 8 +- src/lib/api/skills.ts | 1 + 5 files changed, 159 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 530f6a798..340be9f83 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -171,6 +171,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 aab4bc2c6..e78d70f99 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -159,6 +159,56 @@ 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, +} + +/// 获取 `~/.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 -> (owner, repo_name) +fn parse_agents_lock() -> HashMap { + let path = match dirs::home_dir() { + Some(h) => h.join(".agents").join(".skill-lock.json"), + None => return HashMap::new(), + }; + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + let lock: AgentsLockFile = match serde_json::from_str(&content) { + Ok(l) => l, + Err(_) => return HashMap::new(), + }; + 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('/')?; + Some((name, (owner.to_string(), repo.to_string()))) + }) + .collect() +} + // ========== SkillService ========== pub struct SkillService; @@ -572,10 +622,55 @@ impl SkillService { name, description, found_in: vec![app_str.to_string()], + path: path.display().to_string(), }); } } + // 扫描 ~/.agents/skills/ + if let Some(agents_dir) = get_agents_skills_dir() { + if let Ok(entries) = fs::read_dir(&agents_dir) { + 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; + } + if managed_dirs.contains(&dir_name) { + continue; + } + + 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) + }; + + unmanaged + .entry(dir_name.clone()) + .and_modify(|s| s.found_in.push("agents".to_string())) + .or_insert(UnmanagedSkill { + directory: dir_name, + name, + description, + found_in: vec!["agents".to_string()], + path: path.display().to_string(), + }); + } + } + } + Ok(unmanaged.into_values().collect()) } @@ -587,8 +682,18 @@ impl SkillService { directories: Vec, ) -> Result> { let ssot_dir = Self::get_ssot_dir()?; + let agents_lock = parse_agents_lock(); let mut imported = Vec::new(); + // 一次性获取已有仓库集合,避免循环内重复查询 + 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_repos = HashSet::new(); + for dir_name in directories { // 找到源目录(从任一应用目录复制) let mut source_path: Option = None; @@ -617,6 +722,17 @@ impl SkillService { } } + // 搜索 ~/.agents/skills/ + if let Some(agents_dir) = get_agents_skills_dir() { + let skill_path = agents_dir.join(&dir_name); + if skill_path.exists() { + if source_path.is_none() { + source_path = Some(skill_path); + } + found_in.push("agents".to_string()); + } + } + let source = match source_path { Some(p) => p, None => continue, @@ -654,14 +770,41 @@ impl SkillService { } } + // 从 lock 文件提取仓库信息 + let (id, repo_owner, repo_name) = match agents_lock.get(&dir_name) { + Some((owner, repo)) => { + // 将仓库加入 skill_repos 管理(去重) + let repo_key = (owner.clone(), repo.clone()); + if !existing_repos.contains(&repo_key) && added_repos.insert(repo_key) { + let skill_repo = SkillRepo { + owner: owner.clone(), + name: repo.clone(), + branch: "main".to_string(), + enabled: true, + }; + if let Err(e) = db.save_skill_repo(&skill_repo) { + log::warn!("保存 skill 仓库 {owner}/{repo} 失败: {e}"); + } else { + log::info!("从 agents lock 文件发现并添加仓库: {owner}/{repo}"); + } + } + ( + format!("{owner}/{repo}:{dir_name}"), + Some(owner.clone()), + Some(repo.clone()), + ) + } + None => (format!("local:{dir_name}"), None, None), + }; + // 创建记录 let skill = InstalledSkill { - id: format!("local:{dir_name}"), + id, name, description, directory: dir_name, - repo_owner: None, - repo_name: None, + repo_owner, + repo_name, repo_branch: None, readme_url: None, apps, diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index caa073867..3fa2c3f5e 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -213,9 +213,10 @@ export const SkillsPage = forwardRef( const query = searchQuery.toLowerCase(); return byStatus.filter((skill) => { const name = skill.name?.toLowerCase() || ""; - const repo = skill.repoOwner && skill.repoName - ? `${skill.repoOwner}/${skill.repoName}`.toLowerCase() - : ""; + const repo = + skill.repoOwner && skill.repoName + ? `${skill.repoOwner}/${skill.repoName}`.toLowerCase() + : ""; return name.includes(query) || repo.includes(query); }); diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 6d6f62ce2..7230401fd 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/lib/api/skills.ts b/src/lib/api/skills.ts index e7a6e34f2..b3f3b940f 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -44,6 +44,7 @@ export interface UnmanagedSkill { name: string; description?: string; foundIn: string[]; + path: string; } /** 技能对象(兼容旧 API) */