use std::io::Read;
use std::path::PathBuf;
use serde_json::Value;
use crate::core::discover::{Skill, filter_skills, parse_skill_md_inner};
use crate::core::source::{Source, SourceType, owner_repo};
use crate::error::{Result, SkillsError};
pub fn fetch_skill_via_api(
parsed: &Source,
skill_name: &str,
include_internal: bool,
) -> Result<Option<(tempfile::TempDir, PathBuf)>> {
fetch_skill_via_api_with(parsed, skill_name, include_internal, &http_get)
}
fn http_get(url: &str) -> Result<Vec<u8>> {
let attempt = || -> Result<Vec<u8>> {
let mut req = crate::core::fetch::agent()
.get(url)
.set("User-Agent", "agents-skills");
if let Ok(tok) = std::env::var("GITHUB_TOKEN")
&& !tok.is_empty()
{
req = req.set("Authorization", &format!("Bearer {tok}"));
}
let resp = req.call()?;
let mut buf = Vec::new();
resp.into_reader().read_to_end(&mut buf)?;
Ok(buf)
};
crate::core::fetch::with_retry(3, attempt)
}
fn fetch_skill_via_api_with(
parsed: &Source,
skill_name: &str,
include_internal: bool,
get: &dyn Fn(&str) -> Result<Vec<u8>>,
) -> Result<Option<(tempfile::TempDir, PathBuf)>> {
if parsed.ty != SourceType::Github {
return Ok(None);
}
let owner_repo = owner_repo(&parsed.url);
let (owner, repo) = owner_repo
.split_once('/')
.unwrap_or((owner_repo.as_str(), ""));
let r#ref = match &parsed.r#ref {
Some(r) => r.clone(),
None => {
let body = get(&repo_url(owner, repo))?;
let v: Value = serde_json::from_slice(&body)?;
v.get("default_branch")
.and_then(|b| b.as_str())
.map(str::to_string)
.ok_or_else(|| SkillsError::msg("GitHub API: missing default_branch"))?
}
};
let body = get(&tree_url(owner, repo, &r#ref)?)?;
let v: Value = serde_json::from_slice(&body)?;
if v.get("truncated").and_then(|t| t.as_bool()) == Some(true) {
return Err(SkillsError::msg("GitHub tree listing truncated"));
}
let entries: Vec<(String, String)> = v
.get("tree")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| {
Some((
e.get("path")?.as_str()?.to_string(),
e.get("type")?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default();
let mut candidates: Vec<(usize, String)> = Vec::new();
for (path, ty) in &entries {
if ty != "blob" {
continue;
}
let dir = path.strip_suffix("/SKILL.md").unwrap_or_default();
if path == "SKILL.md" || path.ends_with("/SKILL.md") {
let depth = if dir.is_empty() {
0
} else {
dir.matches('/').count() + 1
};
candidates.push((depth, dir.to_string()));
}
}
candidates.sort_by_key(|(d, _)| *d);
candidates.dedup_by(|a, b| a.1 == b.1);
let scratch = tempfile::TempDir::new()?;
let mut matched: Option<Skill> = None;
'outer: for (_, dir) in &candidates {
let url = raw_url(owner, repo, &r#ref, &format!("{dir}/SKILL.md"))?;
let Ok(bytes) = get(&url) else { continue };
let md = scratch.path().join(dir.replace('/', "_")).join("SKILL.md");
if let Some(parent) = md.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&md, bytes)?;
if let Some(mut skill) = parse_skill_md_inner(&md, include_internal) {
skill.dir = PathBuf::from(dir);
if !filter_skills(std::slice::from_ref(&skill), &[skill_name.to_string()]).is_empty() {
matched = Some(skill);
break 'outer;
}
}
}
let Some(skill) = matched else {
return Ok(None);
};
let dir = skill.dir.to_string_lossy().into_owned();
let out = tempfile::TempDir::new()?;
let files: Vec<String> = if dir.is_empty() {
entries
.iter()
.filter(|(_, t)| t == "blob")
.map(|(p, _)| p.clone())
.collect()
} else {
let prefix = format!("{dir}/");
entries
.iter()
.filter(|(p, t)| t == "blob" && p.starts_with(&prefix))
.map(|(p, _)| p.clone())
.collect()
};
for f in &files {
let url = raw_url(owner, repo, &r#ref, f)?;
let bytes = get(&url)?;
let target = out.path().join(f);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(target, bytes)?;
}
let root = out.path().to_path_buf();
Ok(Some((out, root)))
}
fn repo_url(owner: &str, repo: &str) -> String {
format!("https://api.github.com/repos/{owner}/{repo}")
}
fn tree_url(owner: &str, repo: &str, r#ref: &str) -> Result<String> {
let mut u = url::Url::parse("https://api.github.com").expect("valid base URL");
u.path_segments_mut()
.map_err(|_| SkillsError::msg("invalid GitHub API URL"))?
.push("repos")
.push(owner)
.push(repo)
.push("git")
.push("trees")
.extend(r#ref.split('/'));
u.set_query(Some("recursive=1"));
Ok(u.to_string())
}
fn raw_url(owner: &str, repo: &str, r#ref: &str, path: &str) -> Result<String> {
let mut u = url::Url::parse("https://raw.githubusercontent.com").expect("valid base URL");
u.path_segments_mut()
.map_err(|_| SkillsError::msg("invalid raw URL"))?
.push(owner)
.push(repo)
.extend(r#ref.split('/'))
.extend(path.split('/'));
Ok(u.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::source::parse_source;
fn fake_get(
default_branch: &'static str,
tree: &[(&'static str, &'static str)],
files: &[(&'static str, &'static str)],
) -> impl Fn(&str) -> Result<Vec<u8>> {
move |url: &str| {
if url.contains("/git/trees/") {
let entries: Vec<Value> = tree
.iter()
.map(|(p, t)| serde_json::json!({"path": p, "type": t}))
.collect();
return Ok(serde_json::to_vec(&serde_json::json!({
"tree": entries,
"truncated": false
}))
.unwrap());
}
if url.contains("/repos/acme/skills") {
return Ok(serde_json::to_vec(&serde_json::json!({
"default_branch": default_branch
}))
.unwrap());
}
for (p, content) in files {
if url.ends_with(&format!("/{p}")) {
return Ok(content.as_bytes().to_vec());
}
}
Err(SkillsError::msg(format!("unexpected url: {url}")))
}
}
#[test]
fn fetch_via_api_finds_skill_dir_only() {
let parsed = parse_source("acme/skills@pdf").unwrap();
let tree = [
("pdf/SKILL.md", "blob"),
("pdf/scripts/run.sh", "blob"),
("skills/doc/SKILL.md", "blob"),
("README.md", "blob"),
];
let files = [
("pdf/SKILL.md", "---\nname: pdf\ndescription: d\n---\nbody"),
("pdf/scripts/run.sh", "#!/bin/sh\n"),
(
"skills/doc/SKILL.md",
"---\nname: doc\ndescription: d\n---\nbody",
),
];
let get = fake_get("main", &tree, &files);
let (tmp, root) = fetch_skill_via_api_with(&parsed, "pdf", true, &get)
.unwrap()
.expect("pdf should match");
assert!(root.join("pdf/SKILL.md").is_file());
assert!(root.join("pdf/scripts/run.sh").is_file());
assert!(!root.join("skills/doc/SKILL.md").exists());
assert!(!root.join("README.md").exists());
let _ = tmp;
}
#[test]
fn fetch_via_api_no_match_returns_none() {
let parsed = parse_source("acme/skills@zzz").unwrap();
let tree = [("pdf/SKILL.md", "blob"), ("skills/doc/SKILL.md", "blob")];
let files = [
("pdf/SKILL.md", "---\nname: pdf\ndescription: d\n---\nbody"),
(
"skills/doc/SKILL.md",
"---\nname: doc\ndescription: d\n---\nbody",
),
];
let get = fake_get("main", &tree, &files);
let res = fetch_skill_via_api_with(&parsed, "zzz", true, &get).unwrap();
assert!(res.is_none());
}
#[test]
fn fetch_via_api_api_error_propagates() {
let parsed = parse_source("acme/skills@pdf").unwrap();
let get = |url: &str| -> Result<Vec<u8>> {
Err(SkillsError::msg(format!("network down: {url}")))
};
assert!(fetch_skill_via_api_with(&parsed, "pdf", true, &get).is_err());
}
#[test]
fn fetch_via_api_ignores_non_github() {
let parsed = parse_source("https://gitlab.com/acme/skills/-/tree/main").unwrap();
let get = |_: &str| -> Result<Vec<u8>> { unreachable!("no HTTP for non-github") };
assert!(
fetch_skill_via_api_with(&parsed, "pdf", true, &get)
.unwrap()
.is_none()
);
}
#[test]
fn raw_and_tree_urls_are_encoded() {
assert_eq!(
raw_url("o", "r", "feat/x", "skills/a b/SKILL.md").unwrap(),
"https://raw.githubusercontent.com/o/r/feat/x/skills/a%20b/SKILL.md"
);
assert_eq!(
tree_url("o", "r", "feat/x").unwrap(),
"https://api.github.com/repos/o/r/git/trees/feat/x?recursive=1"
);
}
}