fix(skill): correct skill doc URL branch and path resolution

Use the actual branch returned by download_repo instead of the
configured branch, fixing 404s when repos default to master but
the URL was hardcoded to main. Also switch URL format from /tree/
to /blob/ and always point to the SKILL.md file.

Closes farion1231/cc-switch#968
This commit is contained in:
YoVinchen
2026-02-09 11:17:22 +08:00
parent 5502c74a79
commit 0cf9627e8b

View File

@@ -174,6 +174,29 @@ impl SkillService {
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/ /// 获取 SSOT 目录(~/.cc-switch/skills/
@@ -298,6 +321,8 @@ impl SkillService {
let dest = ssot_dir.join(&install_name); let dest = ssot_dir.join(&install_name);
let mut repo_branch = skill.repo_branch.clone();
// 如果已存在则跳过下载 // 如果已存在则跳过下载
if !dest.exists() { if !dest.exists() {
let repo = SkillRepo { let repo = SkillRepo {
@@ -308,7 +333,7 @@ impl SkillService {
}; };
// 下载仓库 // 下载仓库
let temp_dir = timeout( let (temp_dir, used_branch) = timeout(
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
self.download_repo(&repo), self.download_repo(&repo),
) )
@@ -324,6 +349,7 @@ impl SkillService {
Some("checkNetwork"), Some("checkNetwork"),
)) ))
})??; })??;
repo_branch = used_branch;
// 复制到 SSOT // 复制到 SSOT
let source = temp_dir.join(&skill.directory); let source = temp_dir.join(&skill.directory);
@@ -338,8 +364,39 @@ impl SkillService {
Self::copy_dir_recursive(&source, &dest)?; Self::copy_dir_recursive(&source, &dest)?;
let _ = fs::remove_dir_all(&temp_dir); 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 记录 // 创建 InstalledSkill 记录
let installed_skill = InstalledSkill { let installed_skill = InstalledSkill {
id: skill.key.clone(), id: skill.key.clone(),
@@ -352,8 +409,8 @@ impl SkillService {
directory: install_name.clone(), directory: install_name.clone(),
repo_owner: Some(skill.repo_owner.clone()), repo_owner: Some(skill.repo_owner.clone()),
repo_name: Some(skill.repo_name.clone()), repo_name: Some(skill.repo_name.clone()),
repo_branch: Some(skill.repo_branch.clone()), repo_branch: Some(repo_branch),
readme_url: skill.readme_url.clone(), readme_url,
apps: SkillApps::only(current_app), apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(), installed_at: chrono::Utc::now().timestamp(),
}; };
@@ -862,24 +919,26 @@ impl SkillService {
/// 从仓库获取技能列表 /// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> { async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo)) let (temp_dir, resolved_branch) =
.await timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.map_err(|_| { .await
anyhow!(format_skill_error( .map_err(|_| {
"DOWNLOAD_TIMEOUT", anyhow!(format_skill_error(
&[ "DOWNLOAD_TIMEOUT",
("owner", &repo.owner), &[
("name", &repo.name), ("owner", &repo.owner),
("timeout", "60") ("name", &repo.name),
], ("timeout", "60")
Some("checkNetwork"), ],
)) Some("checkNetwork"),
})??; ))
})??;
let mut skills = Vec::new(); let mut skills = Vec::new();
let scan_dir = temp_dir.clone(); let scan_dir = temp_dir.clone();
let mut resolved_repo = repo.clone();
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?; resolved_repo.branch = resolved_branch;
self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?;
let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&temp_dir);
@@ -907,7 +966,15 @@ impl SkillService {
.to_string() .to_string()
}; };
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) { 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); skills.push(skill);
} }
@@ -931,6 +998,7 @@ impl SkillService {
&self, &self,
skill_md: &Path, skill_md: &Path,
directory: &str, directory: &str,
doc_path: &str,
repo: &SkillRepo, repo: &SkillRepo,
) -> Result<DiscoverableSkill> { ) -> Result<DiscoverableSkill> {
let meta = self.parse_skill_metadata(skill_md)?; let meta = self.parse_skill_metadata(skill_md)?;
@@ -940,9 +1008,11 @@ impl SkillService {
name: meta.name.unwrap_or_else(|| directory.to_string()), name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(), description: meta.description.unwrap_or_default(),
directory: directory.to_string(), directory: directory.to_string(),
readme_url: Some(format!( readme_url: Some(Self::build_skill_doc_url(
"https://github.com/{}/{}/tree/{}/{}", &repo.owner,
repo.owner, repo.name, repo.branch, directory &repo.name,
&repo.branch,
doc_path,
)), )),
repo_owner: repo.owner.clone(), repo_owner: repo.owner.clone(),
repo_name: repo.name.clone(), repo_name: repo.name.clone(),
@@ -994,16 +1064,21 @@ impl SkillService {
} }
/// 下载仓库 /// 下载仓库
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> { async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> {
let temp_dir = tempfile::tempdir()?; let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf(); let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep(); let _ = temp_dir.keep();
let branches = if repo.branch.is_empty() { let mut branches = Vec::new();
vec!["main", "master"] if !repo.branch.is_empty() {
} else { branches.push(repo.branch.as_str());
vec![repo.branch.as_str(), "main", "master"] }
}; if !branches.contains(&"main") {
branches.push("main");
}
if !branches.contains(&"master") {
branches.push("master");
}
let mut last_error = None; let mut last_error = None;
for branch in branches { for branch in branches {
@@ -1014,7 +1089,7 @@ impl SkillService {
match self.download_and_extract(&url, &temp_path).await { match self.download_and_extract(&url, &temp_path).await {
Ok(_) => { Ok(_) => {
return Ok(temp_path); return Ok((temp_path, branch.to_string()));
} }
Err(e) => { Err(e) => {
last_error = Some(e); last_error = Some(e);