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/.
This commit is contained in:
+208
@@ -0,0 +1,208 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user