node-app-build 6.5.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app skills` — manage agent skills from the agentskills repo.
//!
//! Subcommands:
//!   `list`     — list available skills via `gh api`
//!   `install`  — clone the repo and copy skills into the Claude skills dir

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> {
    /// Skill names to install; empty means all available.
    pub names: &'a [String],
    /// true → ~/.claude/skills/, false → .claude/skills/ (project-local).
    pub global: bool,
    /// Overwrite already-installed skills.
    pub update: bool,
    /// Source repo slug, e.g. "econ-v1/agentskills".
    pub repo: &'a str,
}

/// List available skills by querying the GitHub contents API.
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(())
}

/// Clone the remote repo to a temp dir, copy the requested skills, then clean up.
pub fn install(args: SkillsInstallArgs<'_>) -> Result<()> {
    ensure_gh()?;

    let target_dir = resolve_target_dir(args.global)?;

    // Clone to a temp directory (--depth=1 for speed).
    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);
    }

    // Enumerate available skills.
    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();

    // Resolve names to install (empty list → all).
    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(())
}

// ── Helpers ───────────────────────────────────────────────────────────────────

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(())
}

/// Restore executable bit on any `.sh` files under `<skill>/scripts/`.
/// `fs::copy` preserves permissions on Unix, but the gh clone may not have
/// set them correctly if the source lacked the bit. We re-apply 0o755 to
/// every shell script as a safe default.
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
}