fix(skills): prevent duplicate skill installation from different repos (#778)

- Add directory conflict detection before installation
- Fix installed status check to match repo owner and name
- Add i18n translations for conflict error messages
This commit is contained in:
Dex Miller
2026-01-25 23:35:04 +08:00
committed by GitHub
parent 1c6689a0bc
commit 3434dcb87c
6 changed files with 72 additions and 9 deletions

View File

@@ -252,6 +252,50 @@ impl SkillService {
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| skill.directory.clone());
// 检查数据库中是否已有同名 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);
// 如果已存在则跳过下载
@@ -933,10 +977,12 @@ impl SkillService {
Ok(meta)
}
/// 去重技能列表
/// 去重技能列表(基于完整 key不同仓库的同名 skill 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
// 使用完整 keyowner/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);

View File

@@ -65,10 +65,17 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const addRepoMutation = useAddSkillRepo();
const removeRepoMutation = useRemoveSkillRepo();
// 已安装的 directory 集合
const installedDirs = useMemo(() => {
// 已安装的 skill key 集合(使用 directory + repoOwner + repoName 组合判断)
const installedKeys = useMemo(() => {
if (!installedSkills) return new Set<string>();
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
return new Set(
installedSkills.map((s) => {
// 构建唯一 keydirectory + repoOwner + repoName
const owner = s.repoOwner?.toLowerCase() || "";
const name = s.repoName?.toLowerCase() || "";
return `${s.directory.toLowerCase()}:${owner}:${name}`;
}),
);
}, [installedSkills]);
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
@@ -80,12 +87,14 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const installName =
d.directory.split("/").pop()?.toLowerCase() ||
d.directory.toLowerCase();
// 使用 directory + repoOwner + repoName 组合判断是否已安装
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
return {
...d,
installed: installedDirs.has(installName),
installed: installedKeys.has(key),
};
});
}, [discoverableSkills, installedDirs]);
}, [discoverableSkills, installedKeys]);
const loading = loadingDiscoverable || fetchingDiscoverable;

View File

@@ -972,6 +972,7 @@
"downloadTimeoutHint": "Please check network connection or retry later",
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
"skillDirNotFound": "Skill directory not found: {{path}}",
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
"emptyArchive": "Downloaded archive is empty",
"downloadFailed": "Download failed: HTTP {{status}}",
"allBranchesFailed": "All branches failed, tried: {{branches}}",
@@ -990,7 +991,8 @@
"retryLater": "Please retry later",
"checkRepoUrl": "Please check repository URL and branch name",
"checkDiskSpace": "Please check disk space",
"checkPermission": "Please check directory permissions"
"checkPermission": "Please check directory permissions",
"uninstallFirst": "Please uninstall the existing skill with the same name first"
}
},
"repo": {

View File

@@ -972,6 +972,7 @@
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
"emptyArchive": "ダウンロードしたアーカイブが空です",
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
@@ -990,7 +991,8 @@
"retryLater": "時間をおいて再試行してください",
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
"checkDiskSpace": "ディスク容量を確認してください",
"checkPermission": "ディレクトリの権限を確認してください"
"checkPermission": "ディレクトリの権限を確認してください",
"uninstallFirst": "同名のスキルを先にアンインストールしてください"
}
},
"repo": {

View File

@@ -972,6 +972,7 @@
"downloadTimeoutHint": "请检查网络连接或稍后重试",
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
"skillDirNotFound": "技能目录不存在:{{path}}",
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
"emptyArchive": "下载的压缩包为空",
"downloadFailed": "下载失败HTTP {{status}}",
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
@@ -990,7 +991,8 @@
"retryLater": "请稍后重试",
"checkRepoUrl": "请检查仓库地址和分支名称",
"checkDiskSpace": "请检查磁盘空间",
"checkPermission": "请检查目录权限"
"checkPermission": "请检查目录权限",
"uninstallFirst": "请先卸载已安装的同名技能"
}
},
"repo": {

View File

@@ -35,6 +35,7 @@ function getErrorI18nKey(code: string): string {
DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout",
DOWNLOAD_FAILED: "skills.error.downloadFailed",
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
EMPTY_ARCHIVE: "skills.error.emptyArchive",
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
};
@@ -52,6 +53,7 @@ function getSuggestionI18nKey(suggestion: string): string {
retryLater: "skills.error.suggestion.retryLater",
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
checkPermission: "skills.error.suggestion.checkPermission",
uninstallFirst: "skills.error.suggestion.uninstallFirst",
http403: "skills.error.http403",
http404: "skills.error.http404",
http429: "skills.error.http429",