Files
claude-code-skill-manager/src/download.rs
T
siujamo 5a855183ea Initial commit: Claude Code Skill Marketplace manager
HTTP-based CLI tool to discover and install Claude Code skills from
marketplace repositories. Downloads skills via HTTP archive endpoints
(zip/tar.gz) and deploys via copy or symlink into ~/.claude/skills/.
2026-06-18 17:30:52 +08:00

209 lines
6.6 KiB
Rust

use std::fs;
use std::io::{self, Cursor, Read};
use std::path::Path;
pub fn get_json(url: &str) -> Result<String, String> {
let resp = reqwest::blocking::get(url)
.map_err(|e| format!("HTTP request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("HTTP {status} from {url}"));
}
resp.text()
.map_err(|e| format!("Cannot read response body: {e}"))
}
pub fn fetch_marketplace_manifest(repo_url: &str) -> Result<(String, String), String> {
let base = repo_url.trim_end_matches('/').trim_end_matches(".git");
for branch in &["main", "master"] {
let url = format!("{base}/raw/branch/{branch}/skills.json");
match get_json(&url) {
Ok(json) => return Ok((json, branch.to_string())),
Err(_) => continue,
}
}
Err(format!(
"Cannot fetch skills.json from {base}. Tried branches 'main' and 'master'.\n\
Make sure the repository is public and contains skills.json at its root."
))
}
pub fn download_skill(repo_url: &str, branch: &str, dest: &Path) -> Result<(), String> {
let base = repo_url.trim_end_matches('/').trim_end_matches(".git");
let mut last_err = String::new();
for ext in &["zip", "tar.gz"] {
let url = format!("{base}/archive/{branch}.{ext}");
match download_and_extract(&url, dest) {
Ok(()) => return Ok(()),
Err(e) => {
last_err = e;
continue;
}
}
}
Err(format!("Cannot download skill from {base}: {last_err}"))
}
fn download_and_extract(url: &str, dest: &Path) -> Result<(), String> {
let resp = reqwest::blocking::get(url)
.map_err(|e| format!("HTTP request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
let bytes = resp
.bytes()
.map_err(|e| format!("Cannot read response body: {e}"))?;
if url.ends_with(".zip") {
extract_zip(&bytes, dest)
} else if url.ends_with(".tar.gz") {
extract_tar_gz(&bytes, dest)
} else if bytes.len() >= 2 && &bytes[..2] == b"PK" {
extract_zip(&bytes, dest)
} else if bytes.len() >= 2 && &bytes[..2] == b"\x1f\x8b" {
extract_tar_gz(&bytes, dest)
} else {
Err("Unknown archive format".to_string())
}
}
fn extract_zip(data: &[u8], dest: &Path) -> Result<(), String> {
let cursor = Cursor::new(data);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Cannot open zip: {e}"))?;
let prefix = detect_strip_prefix_zip(&mut archive);
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Cannot read zip entry: {e}"))?;
let name = file.name().to_string();
let entry_path = match &prefix {
Some(p) => match name.strip_prefix(p) {
Some(rest) if !rest.is_empty() => rest,
_ => continue,
},
None => &name,
};
let out_path = dest.join(entry_path);
if file.is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Cannot create dir {}: {e}", out_path.display()))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let mut out = fs::File::create(&out_path)
.map_err(|e| format!("Cannot create file {}: {e}", out_path.display()))?;
io::copy(&mut file, &mut out)
.map_err(|e| format!("Cannot write file {}: {e}", out_path.display()))?;
}
}
Ok(())
}
fn extract_tar_gz(data: &[u8], dest: &Path) -> Result<(), String> {
let decoder = flate2::read::GzDecoder::new(data);
let mut archive = tar::Archive::new(decoder);
let entries: Vec<_> = archive
.entries()
.map_err(|e| format!("Cannot read tar: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Cannot read tar entry: {e}"))?;
let prefix = detect_strip_prefix_tar(&entries);
for mut entry in entries {
let path = entry
.path()
.map_err(|e| format!("Cannot read entry path: {e}"))?;
let path_str = path.to_string_lossy().to_string();
let entry_path = match &prefix {
Some(p) => match path_str.strip_prefix(p) {
Some(rest) if !rest.is_empty() => rest,
_ => continue,
},
None => &path_str,
};
let out_path = dest.join(entry_path);
if entry.header().entry_type().is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Cannot create dir {}: {e}", out_path.display()))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
entry
.unpack(&out_path)
.map_err(|e| format!("Cannot unpack {}: {e}", out_path.display()))?;
}
}
Ok(())
}
fn detect_strip_prefix_zip(archive: &mut zip::ZipArchive<Cursor<&[u8]>>) -> Option<String> {
let mut top_dirs: Vec<String> = Vec::new();
for i in 0..archive.len() {
if let Ok(file) = archive.by_index(i) {
let name = file.name();
if let Some(slash_pos) = name.find('/') {
let top = &name[..=slash_pos];
if top_dirs.is_empty() || top_dirs[0] == top {
top_dirs.push(top.to_string());
} else {
return None;
}
} else if !name.is_empty() {
return None;
}
}
}
if top_dirs.len() >= 2 {
Some(top_dirs[0].clone())
} else {
None
}
}
fn detect_strip_prefix_tar(entries: &[tar::Entry<impl Read>]) -> Option<String> {
let mut prefix: Option<String> = None;
for entry in entries {
if let Ok(path) = entry.path() {
let path_str = path.to_string_lossy().to_string();
if let Some(slash_pos) = path_str.find('/') {
let top = &path_str[..=slash_pos];
match &prefix {
Some(p) if p != top => return None,
None => prefix = Some(top.to_string()),
_ => {}
}
} else if !path_str.is_empty() {
return None;
}
}
}
prefix
}