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
This commit is contained in:
YoVinchen
2026-02-14 14:14:40 +08:00
parent 688b310021
commit 74dcc0bb3e
5 changed files with 159 additions and 8 deletions
+2
View File
@@ -171,6 +171,8 @@ pub struct UnmanagedSkill {
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
/// 发现路径(首个匹配的完整路径)
pub path: String,
}
/// MCP 服务器定义(v3.7.0 统一结构)
+146 -3
View File
@@ -159,6 +159,56 @@ pub struct SkillMetadata {
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>,
}
/// 获取 `~/.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 -> (owner, repo_name)
fn parse_agents_lock() -> HashMap<String, (String, String)> {
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<String>,
) -> Result<Vec<InstalledSkill>> {
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<PathBuf> = 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,
+4 -3
View File
@@ -213,9 +213,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
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);
});
+6 -2
View File
@@ -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<ImportSkillsDialogProps> = ({
{skill.description}
</div>
)}
<div className="text-xs text-muted-foreground/70 mt-1">
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
<div
className="text-xs text-muted-foreground/50 mt-1 truncate"
title={skill.path}
>
{skill.path}
</div>
</div>
</label>
+1
View File
@@ -44,6 +44,7 @@ export interface UnmanagedSkill {
name: string;
description?: string;
foundIn: string[];
path: string;
}
/** 技能对象(兼容旧 API) */