use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct SkillsInstallArgs<'a> {
pub names: &'a [String],
pub global: bool,
pub update: bool,
pub repo: &'a str,
}
pub fn list(repo: &str) -> Result<()> {
ensure_gh()?;
let endpoint = format!("repos/{repo}/contents/skills");
let output = Command::new("gh")
.args(["api", &endpoint])
.output()
.with_context(|| "invoke `gh api`")?;
if !output.status.success() {
bail!(
"gh api failed (exit {}):\n{}\nEnsure you have access to {} and are authenticated (`gh auth login`).",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim(),
repo,
);
}
let entries: Vec<serde_json::Value> =
serde_json::from_slice(&output.stdout).context("parse gh api response")?;
let skills: Vec<&str> = entries
.iter()
.filter(|e| e["type"] == "dir")
.filter_map(|e| e["name"].as_str())
.collect();
if skills.is_empty() {
println!("No skills found in {repo}/skills");
return Ok(());
}
println!("Available skills in {repo} ({}):", skills.len());
for name in &skills {
println!(" {name}");
}
Ok(())
}
pub fn install(args: SkillsInstallArgs<'_>) -> Result<()> {
ensure_gh()?;
let target_dir = resolve_target_dir(args.global)?;
let tmp = temp_dir()?;
let clone_ok = Command::new("gh")
.args([
"repo",
"clone",
args.repo,
tmp.to_str().unwrap_or_default(),
"--",
"--depth=1",
"--quiet",
])
.status()
.with_context(|| format!("invoke `gh repo clone {}`", args.repo))?
.success();
if !clone_ok {
let _ = fs::remove_dir_all(&tmp);
bail!(
"Failed to clone {}. Ensure you have access and are authenticated (`gh auth login`).",
args.repo
);
}
let skills_src = tmp.join("skills");
if !skills_src.is_dir() {
let _ = fs::remove_dir_all(&tmp);
bail!("Repository {} has no skills/ directory.", args.repo);
}
let available: Vec<String> = fs::read_dir(&skills_src)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
let to_install: Vec<&str> = if args.names.is_empty() {
available.iter().map(String::as_str).collect()
} else {
let mut selected = Vec::new();
for name in args.names {
if available.iter().any(|a| a == name) {
selected.push(name.as_str());
} else {
eprintln!(
"→ skill '{name}' not found in {} — available: {}",
args.repo,
available.join(", ")
);
}
}
selected
};
if to_install.is_empty() {
let _ = fs::remove_dir_all(&tmp);
bail!("No matching skills to install.");
}
fs::create_dir_all(&target_dir)
.with_context(|| format!("create {}", target_dir.display()))?;
let mut installed = 0usize;
let mut skipped = 0usize;
for skill_name in &to_install {
let src = skills_src.join(skill_name);
let dst = target_dir.join(skill_name);
if dst.exists() && !args.update {
eprintln!(
"→ '{skill_name}' already installed at {} — pass --update to overwrite",
dst.display()
);
skipped += 1;
continue;
}
if dst.exists() {
fs::remove_dir_all(&dst)
.with_context(|| format!("remove existing {}", dst.display()))?;
}
copy_dir_all(&src, &dst)
.with_context(|| format!("copy '{skill_name}' to {}", dst.display()))?;
restore_exec_bits(&dst);
println!("✓ {skill_name}");
installed += 1;
}
let _ = fs::remove_dir_all(&tmp);
println!();
println!(
"{installed} skill(s) installed → {}",
target_dir.display()
);
if skipped > 0 {
println!("{skipped} skill(s) skipped (already installed; use --update to overwrite)");
}
Ok(())
}
fn resolve_target_dir(global: bool) -> Result<PathBuf> {
if global {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.context("HOME environment variable not set")?;
Ok(home.join(".claude/skills"))
} else {
Ok(PathBuf::from(".claude/skills"))
}
}
fn temp_dir() -> Result<PathBuf> {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
Ok(std::env::temp_dir().join(format!("node-app-skills-{ts}")))
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let dst_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&entry.path(), &dst_path)?;
} else {
fs::copy(entry.path(), &dst_path)?;
}
}
Ok(())
}
fn restore_exec_bits(skill_dir: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let scripts = skill_dir.join("scripts");
if !scripts.is_dir() {
return;
}
if let Ok(rd) = fs::read_dir(&scripts) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().map(|e| e == "sh").unwrap_or(false) {
if let Ok(meta) = fs::metadata(&p) {
let mut perm = meta.permissions();
perm.set_mode(0o755);
let _ = fs::set_permissions(&p, perm);
}
}
}
}
}
#[cfg(not(unix))]
let _ = skill_dir;
}
fn ensure_gh() -> Result<()> {
if which("gh").is_none() {
bail!(
"gh CLI not found on PATH.\n\
Install from https://cli.github.com then authenticate with `gh auth login`."
);
}
Ok(())
}
fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
if candidate.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = candidate.metadata() {
if meta.permissions().mode() & 0o111 != 0 {
return Some(candidate);
}
}
}
#[cfg(not(unix))]
return Some(candidate);
}
}
None
}