use std::process::Command;
use std::sync::OnceLock;
use skillfile_core::error::SkillfileError;
static TOKEN_CACHE: OnceLock<Option<String>> = OnceLock::new();
static CONFIG_TOKEN: OnceLock<Option<String>> = OnceLock::new();
pub fn set_config_token(token: Option<String>) {
let _ = CONFIG_TOKEN.set(token);
}
pub struct GithubToken(Option<&'static str>);
impl GithubToken {
#[must_use]
pub fn for_url(&self, url: &str) -> Option<&'static str> {
is_github_url(url).then_some(self.0).flatten()
}
}
#[must_use]
pub fn github_token() -> GithubToken {
GithubToken(TOKEN_CACHE.get_or_init(discover_github_token).as_deref())
}
fn env_token(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|t| !t.is_empty())
}
fn gh_cli_token() -> Option<String> {
let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
if !output.status.success() {
return None;
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!token.is_empty()).then_some(token)
}
fn discover_github_token() -> Option<String> {
if let Some(token) = env_token("GITHUB_TOKEN") {
return Some(token);
}
if let Some(token) = env_token("GH_TOKEN") {
return Some(token);
}
if let Some(Some(token)) = CONFIG_TOKEN.get() {
if !token.is_empty() {
return Some(token.clone());
}
}
gh_cli_token()
}
pub struct BearerPost<'a> {
pub url: &'a str,
pub body: &'a str,
pub token: &'a str,
}
pub trait HttpClient: Send + Sync {
fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError>;
fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError>;
fn post_json(&self, url: &str, body: &str) -> Result<Vec<u8>, SkillfileError>;
fn post_json_with_bearer(&self, req: &BearerPost<'_>) -> Result<Vec<u8>, SkillfileError> {
self.post_json(req.url, req.body)
}
}
fn is_github_url(url: &str) -> bool {
let host = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.and_then(|s| s.split('/').next())
.unwrap_or("");
matches!(host, "api.github.com" | "raw.githubusercontent.com")
}
fn read_response_text(body: &mut ureq::Body, url: &str) -> Result<String, SkillfileError> {
body.read_to_string()
.map_err(|e| SkillfileError::Network(format!("failed to read response from {url}: {e}")))
}
pub struct UreqClient {
agent: ureq::Agent,
}
impl UreqClient {
pub fn new() -> Self {
let config = ureq::config::Config::builder()
.redirect_auth_headers(ureq::config::RedirectAuthHeaders::SameHost)
.build();
Self {
agent: ureq::Agent::new_with_config(config),
}
}
fn build_get(&self, url: &str) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
let mut req = self.agent.get(url).header("User-Agent", "skillfile/1.0");
if let Some(token) = github_token().for_url(url) {
req = req.header("Authorization", &format!("Bearer {token}"));
}
req
}
fn build_post(&self, url: &str) -> ureq::RequestBuilder<ureq::typestate::WithBody> {
let mut req = self.agent.post(url).header("User-Agent", "skillfile/1.0");
if let Some(token) = github_token().for_url(url) {
req = req.header("Authorization", &format!("Bearer {token}"));
}
req
}
}
impl Default for UreqClient {
fn default() -> Self {
Self::new()
}
}
impl HttpClient for UreqClient {
fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError> {
let mut response = self.build_get(url).call().map_err(|e| match &e {
ureq::Error::StatusCode(404) => SkillfileError::Network(format!(
"HTTP 404: {url} not found — check that the path exists in the upstream repo"
)),
ureq::Error::StatusCode(code) => {
SkillfileError::Network(format!("HTTP {code} fetching {url}"))
}
_ => SkillfileError::Network(format!("{e} fetching {url}")),
})?;
response.body_mut().read_to_vec().map_err(|e| {
SkillfileError::Network(format!("failed to read response from {url}: {e}"))
})
}
fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError> {
let result = self
.build_get(url)
.header("Accept", "application/vnd.github.v3+json")
.call();
match result {
Ok(mut response) => read_response_text(response.body_mut(), url).map(Some),
Err(ureq::Error::StatusCode(code)) if code == 404 || code == 422 => Ok(None),
Err(ureq::Error::StatusCode(403)) => Err(SkillfileError::Network(format!(
"HTTP 403 fetching {url} — you may be rate-limited. \
Set GITHUB_TOKEN or run `gh auth login` to authenticate."
))),
Err(e) => Err(SkillfileError::Network(format!("{e} fetching {url}"))),
}
}
fn post_json(&self, url: &str, body: &str) -> Result<Vec<u8>, SkillfileError> {
let mut response = self
.build_post(url)
.header("Content-Type", "application/json")
.send(body.as_bytes())
.map_err(|e| match &e {
ureq::Error::StatusCode(code) => {
SkillfileError::Network(format!("HTTP {code} posting to {url}"))
}
_ => SkillfileError::Network(format!("{e} posting to {url}")),
})?;
response.body_mut().read_to_vec().map_err(|e| {
SkillfileError::Network(format!("failed to read response from {url}: {e}"))
})
}
fn post_json_with_bearer(&self, req: &BearerPost<'_>) -> Result<Vec<u8>, SkillfileError> {
let (url, token) = (req.url, req.token);
let mut response = self
.agent
.post(url)
.header("User-Agent", "skillfile/1.0")
.header("Content-Type", "application/json")
.header("Authorization", &format!("Bearer {token}"))
.send(req.body.as_bytes())
.map_err(|e| match &e {
ureq::Error::StatusCode(code) => {
SkillfileError::Network(format!("HTTP {code} posting to {url}"))
}
_ => SkillfileError::Network(format!("{e} posting to {url}")),
})?;
response.body_mut().read_to_vec().map_err(|e| {
SkillfileError::Network(format!("failed to read response from {url}: {e}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ureq_client_default_creates_successfully() {
let _client = UreqClient::default();
}
#[test]
fn set_config_token_populates_cache() {
set_config_token(Some("test-token-abc".to_string()));
assert!(CONFIG_TOKEN.get().is_some());
}
#[test]
fn github_token_type_for_url_rejects_registries() {
let token = GithubToken(Some("ghp_secret"));
assert!(token.for_url("https://agentskill.sh/api/search").is_none());
assert!(token.for_url("https://skills.sh/api/search").is_none());
assert!(token
.for_url("https://www.skillhub.club/api/v1/skills/search")
.is_none());
}
#[test]
fn github_token_type_for_url_allows_github() {
let token = GithubToken(Some("ghp_secret"));
assert_eq!(
token.for_url("https://api.github.com/repos/o/r"),
Some("ghp_secret")
);
assert_eq!(
token.for_url("https://raw.githubusercontent.com/o/r/HEAD/f"),
Some("ghp_secret")
);
}
#[test]
fn github_token_type_for_url_returns_none_without_token() {
let token = GithubToken(None);
assert!(token.for_url("https://api.github.com/repos/o/r").is_none());
}
#[test]
fn github_api_url_is_github() {
assert!(is_github_url("https://api.github.com/repos/owner/repo"));
}
#[test]
fn github_raw_url_is_github() {
assert!(is_github_url(
"https://raw.githubusercontent.com/owner/repo/main/file.md"
));
}
#[test]
fn github_api_root_is_github() {
assert!(is_github_url("https://api.github.com/"));
}
#[test]
fn agentskill_url_is_not_github() {
assert!(!is_github_url(
"https://agentskill.sh/api/agent/search?q=test"
));
}
#[test]
fn skillssh_url_is_not_github() {
assert!(!is_github_url("https://skills.sh/api/search?q=test"));
}
#[test]
fn skillhub_url_is_not_github() {
assert!(!is_github_url(
"https://www.skillhub.club/api/v1/skills/search"
));
}
#[test]
fn spoofed_github_subdomain_is_not_github() {
assert!(!is_github_url("https://api.github.com.evil.com/repos"));
}
#[test]
fn spoofed_raw_subdomain_is_not_github() {
assert!(!is_github_url(
"https://raw.githubusercontent.com.evil.com/file"
));
}
#[test]
fn empty_url_is_not_github() {
assert!(!is_github_url(""));
}
#[test]
fn bare_domain_is_not_github() {
assert!(!is_github_url("api.github.com/repos"));
}
#[test]
fn http_github_url_is_github() {
assert!(is_github_url("http://api.github.com/repos/owner/repo"));
}
}