diff --git a/README.md b/README.md index 77bb41ba..b80bd38d 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,26 @@ cc-switch skills repos enable # Enable repo without changing branch cc-switch skills repos disable # Disable repo without changing branch ``` +**Private GitHub skill repositories:** Repo records only store `owner/name/branch/enabled`; tokens are never persisted. When downloading GitHub ZIP archives, `cc-switch` reads tokens from environment variables in this priority order: + +```text +CC_SWITCH_SKILLS_GITHUB_TOKEN_OWNER_REPO +CC_SWITCH_SKILLS_GITHUB_TOKEN_OWNER +CC_SWITCH_SKILLS_GITHUB_TOKEN +GITHUB_TOKEN +GH_TOKEN +``` + +`OWNER` and `REPO` are uppercased, and non-alphanumeric characters become `_`. For example, `acme/private-skills` can use: + +```bash +export CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME_PRIVATE_SKILLS="ghp_xxx" +cc-switch skills repos add acme/private-skills@main +cc-switch skills discover +``` + +The token needs at least repository contents read access. Do not write tokens into config files or commit them to the repository. + ### ⚙️ Configuration Management Manage configuration backups, imports, and exports. diff --git a/README_ZH.md b/README_ZH.md index f9e94166..fe36088e 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -321,6 +321,26 @@ cc-switch skills repos enable # 启用仓库但保留当前分支 cc-switch skills repos disable # 禁用仓库但保留当前分支 ``` +**私有 GitHub Skill 仓库:** 仓库配置只保存 `owner/name/branch/enabled`,不会持久化 token。下载 GitHub ZIP 时会按以下优先级读取环境变量: + +```text +CC_SWITCH_SKILLS_GITHUB_TOKEN_OWNER_REPO +CC_SWITCH_SKILLS_GITHUB_TOKEN_OWNER +CC_SWITCH_SKILLS_GITHUB_TOKEN +GITHUB_TOKEN +GH_TOKEN +``` + +`OWNER` 和 `REPO` 会转为大写,非字母数字字符会转为 `_`。例如 `acme/private-skills` 可使用: + +```bash +export CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME_PRIVATE_SKILLS="ghp_xxx" +cc-switch skills repos add acme/private-skills@main +cc-switch skills discover +``` + +token 至少需要仓库内容读取权限;不要把 token 写入配置文件或提交到仓库。 + ### ⚙️ 配置管理 管理配置文件的备份、导入和导出。 diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f90df1a1..f73fe64a 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -398,7 +398,7 @@ impl SkillService { pub fn new() -> Result { let http_client = Client::builder() .user_agent("cc-switch") - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(60)) .build() .map_err(|e| { AppError::localized( diff --git a/src-tauri/src/services/skill/discovery.rs b/src-tauri/src/services/skill/discovery.rs index b5f22c10..dd1b6c55 100644 --- a/src-tauri/src/services/skill/discovery.rs +++ b/src-tauri/src/services/skill/discovery.rs @@ -1,5 +1,9 @@ use super::*; +const GITHUB_API_ACCEPT: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION: &str = "2022-11-28"; +const SKILLS_GITHUB_TOKEN_ENV: &str = "CC_SWITCH_SKILLS_GITHUB_TOKEN"; + impl SkillService { pub(super) fn merge_local_ssot_skills( index: &SkillsIndex, @@ -221,14 +225,40 @@ impl SkillService { vec![repo.branch.as_str(), "main", "master"] }; + let token = Self::github_token_for_repo(repo); let mut last_error: Option = None; for branch in branches { - let url = format!( - "https://github.com/{}/{}/archive/refs/heads/{}.zip", - repo.owner, repo.name, branch - ); + if let Some(token) = token.as_deref() { + match Self::github_api_zipball_url(repo, branch) { + Ok(url) => match self + .download_and_extract(&url, &temp_path, Some(token)) + .await + { + Ok(()) => return Ok(temp_path), + Err(e) => { + last_error = Some(e); + } + }, + Err(e) => { + last_error = Some(e); + } + } - match self.download_and_extract(&url, &temp_path).await { + // Private repos need a token; public repos should ignore bad global tokens. + let url = Self::github_archive_url(repo, branch); + if self + .download_and_extract(&url, &temp_path, None) + .await + .is_ok() + { + return Ok(temp_path); + } + + continue; + } + + let url = Self::github_archive_url(repo, branch); + match self.download_and_extract(&url, &temp_path, None).await { Ok(()) => return Ok(temp_path), Err(e) => { last_error = Some(e); @@ -250,8 +280,17 @@ impl SkillService { &self, url: &str, dest: &Path, + token: Option<&str>, ) -> Result<(), AppError> { - let response = self.http_client.get(url).send().await.map_err(|e| { + let mut request = self.http_client.get(url); + if let Some(token) = token.map(str::trim).filter(|token| !token.is_empty()) { + request = request + .header(reqwest::header::ACCEPT, GITHUB_API_ACCEPT) + .header("X-GitHub-Api-Version", GITHUB_API_VERSION) + .bearer_auth(token); + } + + let response = request.send().await.map_err(|e| { AppError::localized( "skills.download_failed", format!("下载失败: {e}"), @@ -312,15 +351,13 @@ impl SkillService { let mut file = archive .by_index(i) .map_err(|e| AppError::Message(e.to_string()))?; - let file_path = file.name(); - - let relative_path = - if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) { - stripped - } else { - continue; - }; - if relative_path.is_empty() { + let Some(safe_path) = file.enclosed_name() else { + continue; + }; + let Ok(relative_path) = safe_path.strip_prefix(&root_name) else { + continue; + }; + if relative_path.as_os_str().is_empty() { continue; } @@ -343,6 +380,63 @@ impl SkillService { Ok(()) } + fn github_archive_url(repo: &SkillRepo, branch: &str) -> String { + format!( + "https://github.com/{}/{}/archive/refs/heads/{}.zip", + repo.owner, repo.name, branch + ) + } + + fn github_api_zipball_url(repo: &SkillRepo, branch: &str) -> Result { + let mut url = reqwest::Url::parse("https://api.github.com/") + .map_err(|e| AppError::Message(format!("Failed to build GitHub zipball URL: {e}")))?; + url.path_segments_mut() + .map_err(|_| AppError::Message("Failed to build GitHub zipball URL".to_string()))? + .extend(["repos", &repo.owner, &repo.name, "zipball", branch]); + Ok(url.to_string()) + } + + fn github_token_for_repo(repo: &SkillRepo) -> Option { + Self::github_token_from_lookup(repo, |key| std::env::var(key).ok()) + } + + fn github_token_from_lookup(repo: &SkillRepo, mut lookup: F) -> Option + where + F: FnMut(&str) -> Option, + { + Self::github_token_env_keys(repo) + .into_iter() + .filter_map(|key| lookup(&key)) + .map(|token| token.trim().to_string()) + .find(|token| !token.is_empty()) + } + + fn github_token_env_keys(repo: &SkillRepo) -> Vec { + let owner = Self::github_env_segment(&repo.owner); + let name = Self::github_env_segment(&repo.name); + + vec![ + format!("{SKILLS_GITHUB_TOKEN_ENV}_{owner}_{name}"), + format!("{SKILLS_GITHUB_TOKEN_ENV}_{owner}"), + SKILLS_GITHUB_TOKEN_ENV.to_string(), + "GITHUB_TOKEN".to_string(), + "GH_TOKEN".to_string(), + ] + } + + fn github_env_segment(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() + } + pub(super) fn scan_skill_dirs(root: &Path) -> Result, AppError> { let mut results = Vec::new(); let mut stack = vec![root.to_path_buf()]; @@ -425,3 +519,76 @@ impl SkillService { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn repo(owner: &str, name: &str) -> SkillRepo { + SkillRepo { + owner: owner.to_string(), + name: name.to_string(), + branch: "main".to_string(), + enabled: true, + } + } + + #[test] + fn github_token_env_keys_follow_expected_priority() { + let keys = SkillService::github_token_env_keys(&repo("acme-inc", "private.skills")); + + assert_eq!( + keys, + vec![ + "CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME_INC_PRIVATE_SKILLS", + "CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME_INC", + "CC_SWITCH_SKILLS_GITHUB_TOKEN", + "GITHUB_TOKEN", + "GH_TOKEN" + ] + ); + } + + #[test] + fn github_token_lookup_prefers_repo_specific_token() { + let values = HashMap::from([ + ("CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME_PRIVATE", "repo-token"), + ("CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME", "owner-token"), + ("CC_SWITCH_SKILLS_GITHUB_TOKEN", "global-token"), + ("GITHUB_TOKEN", "github-token"), + ("GH_TOKEN", "gh-token"), + ]); + + let token = SkillService::github_token_from_lookup(&repo("acme", "private"), |key| { + values.get(key).map(|value| value.to_string()) + }); + + assert_eq!(token.as_deref(), Some("repo-token")); + } + + #[test] + fn github_token_lookup_skips_empty_values() { + let values = HashMap::from([ + ("CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME_PRIVATE", " "), + ("CC_SWITCH_SKILLS_GITHUB_TOKEN_ACME", "owner-token"), + ]); + + let token = SkillService::github_token_from_lookup(&repo("acme", "private"), |key| { + values.get(key).map(|value| value.to_string()) + }); + + assert_eq!(token.as_deref(), Some("owner-token")); + } + + #[test] + fn github_zipball_url_encodes_branch_as_path_segment() { + let url = SkillService::github_api_zipball_url(&repo("acme", "private"), "feature/a") + .expect("zipball URL should build"); + + assert_eq!( + url, + "https://api.github.com/repos/acme/private/zipball/feature%2Fa" + ); + } +}