skiller 0.8.0

Declarative project and global skill management over the Vercel Skills CLI
use std::env;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};

use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde::de::DeserializeOwned;

static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);

pub fn config_root() -> Result<PathBuf> {
    if let Some(path) = env::var_os("SKILLER_CONFIG_HOME") {
        return Ok(PathBuf::from(path));
    }
    if let Some(path) = env::var_os("XDG_CONFIG_HOME") {
        return Ok(PathBuf::from(path).join("skiller"));
    }
    Ok(home_dir()?.join(".config/skiller"))
}

pub fn cache_root() -> Result<PathBuf> {
    if let Some(path) = env::var_os("SKILLER_CACHE_HOME") {
        return Ok(PathBuf::from(path));
    }
    if let Some(path) = env::var_os("XDG_CACHE_HOME") {
        return Ok(PathBuf::from(path).join("skiller"));
    }
    Ok(home_dir()?.join(".cache/skiller"))
}

pub fn state_root() -> Result<PathBuf> {
    if let Some(path) = env::var_os("SKILLER_STATE_HOME") {
        return Ok(PathBuf::from(path));
    }
    if let Some(path) = env::var_os("XDG_STATE_HOME") {
        return Ok(PathBuf::from(path).join("skiller"));
    }
    Ok(home_dir()?.join(".local/state/skiller"))
}

pub fn global_config_path() -> Result<PathBuf> {
    Ok(config_root()?.join("config.json"))
}

pub fn global_state_path() -> Result<PathBuf> {
    Ok(state_root()?.join("installed.json"))
}

pub fn global_skills_root() -> Result<PathBuf> {
    Ok(home_dir()?.join(".agents/skills"))
}

pub fn project_root() -> Result<PathBuf> {
    let cwd = env::current_dir().context("reading current directory")?;
    let output = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .current_dir(&cwd)
        .output();
    if let Ok(output) = output
        && output.status.success()
    {
        let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
        if !value.is_empty() {
            return PathBuf::from(value)
                .canonicalize()
                .context("resolving Git project root");
        }
    }
    cwd.canonicalize().context("resolving project root")
}

pub fn read_json_or_default<T>(path: &Path) -> Result<T>
where
    T: DeserializeOwned + Default,
{
    match std::fs::read_to_string(path) {
        Ok(raw) => {
            serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
        Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
    }
}

pub fn validate_managed_json_path(path: &Path) -> Result<()> {
    let parent = path.parent().context("managed JSON path has no parent")?;
    match std::fs::symlink_metadata(parent) {
        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
            bail!(
                "managed state parent must be a real directory: {}",
                parent.display()
            )
        }
        Ok(_) => {}
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(error) => {
            return Err(error).with_context(|| format!("inspecting {}", parent.display()));
        }
    }
    match std::fs::symlink_metadata(path) {
        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
            bail!("managed state must be a real file: {}", path.display())
        }
        Ok(_) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error).with_context(|| format!("inspecting {}", path.display())),
    }
}

pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
    let raw =
        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
}

pub fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
    write_json_atomic_bytes(path, serde_json::to_vec_pretty(value)?)
}

pub fn write_json_atomic_compact<T: Serialize>(path: &Path, value: &T) -> Result<()> {
    write_json_atomic_bytes(path, serde_json::to_vec(value)?)
}

pub fn write_json_exclusive_compact<T: Serialize>(path: &Path, value: &T) -> Result<()> {
    let parent = path.parent().context("JSON path has no parent")?;
    ensure_real_dir(parent)?;
    let bytes = serde_json::to_vec(value)?;
    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
        .with_context(|| format!("acquiring exclusive transaction {}", path.display()))?;
    if let Err(error) = file
        .write_all(&bytes)
        .and_then(|_| file.write_all(b"\n"))
        .and_then(|_| file.sync_all())
    {
        drop(file);
        let _ = std::fs::remove_file(path);
        return Err(error).with_context(|| format!("writing {}", path.display()));
    }
    Ok(())
}

fn write_json_atomic_bytes(path: &Path, bytes: Vec<u8>) -> Result<()> {
    let parent = path.parent().context("JSON path has no parent")?;
    ensure_real_dir(parent)?;
    if let Ok(metadata) = std::fs::symlink_metadata(path)
        && metadata.file_type().is_symlink()
    {
        bail!("refusing to replace symlinked file: {}", path.display());
    }
    let temporary = parent.join(format!(
        ".{}.{}.{}.tmp",
        path.file_name().unwrap_or_default().to_string_lossy(),
        std::process::id(),
        TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed)
    ));
    let write_result = (|| -> Result<()> {
        let mut file = std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&temporary)
            .with_context(|| format!("creating {}", temporary.display()))?;
        file.write_all(&bytes)
            .and_then(|_| file.write_all(b"\n"))
            .with_context(|| format!("writing {}", temporary.display()))?;
        file.sync_all()
            .with_context(|| format!("syncing {}", temporary.display()))?;
        Ok(())
    })();
    if let Err(error) = write_result {
        let _ = std::fs::remove_file(&temporary);
        return Err(error);
    }
    if let Err(error) = std::fs::rename(&temporary, path) {
        let _ = std::fs::remove_file(&temporary);
        return Err(error).with_context(|| format!("committing {}", path.display()));
    }
    Ok(())
}

pub fn write_global_config(value: &crate::model::GlobalConfig) -> Result<()> {
    let path = global_config_path()?;
    let destination = match std::fs::symlink_metadata(&path) {
        Ok(metadata) if metadata.file_type().is_symlink() => {
            let target = std::fs::read_link(&path)
                .with_context(|| format!("reading global config symlink {}", path.display()))?;
            if target.is_absolute() {
                target
            } else {
                path.parent()
                    .context("global config symlink has no parent")?
                    .join(target)
            }
        }
        Ok(_) => path,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => path,
        Err(error) => return Err(error).context("inspecting global config path"),
    };
    write_json_atomic(&destination, value)
}

pub fn ensure_real_dir(path: &Path) -> Result<()> {
    if path.exists() {
        let metadata = std::fs::symlink_metadata(path)?;
        if metadata.file_type().is_symlink() || !metadata.is_dir() {
            bail!("expected a real directory: {}", path.display());
        }
        return Ok(());
    }
    if let Some(parent) = path.parent()
        && parent != path
    {
        ensure_real_dir(parent)?;
    }
    std::fs::create_dir(path).with_context(|| format!("creating {}", path.display()))
}

pub fn safe_remove_owned_dir(path: &Path, allowed_parent: &Path) -> Result<()> {
    let parent = path.parent().context("owned path has no parent")?;
    if parent != allowed_parent || path.file_name().is_none() {
        bail!(
            "refusing to remove path outside managed root: {}",
            path.display()
        );
    }
    match std::fs::symlink_metadata(path) {
        Ok(metadata) if metadata.file_type().is_symlink() => {
            bail!(
                "refusing to remove symlinked managed path: {}",
                path.display()
            )
        }
        Ok(metadata) if metadata.is_dir() => {
            std::fs::remove_dir_all(path).with_context(|| format!("removing {}", path.display()))
        }
        Ok(_) => bail!("managed path is not a directory: {}", path.display()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error).with_context(|| format!("inspecting {}", path.display())),
    }
}

pub fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
    let metadata = std::fs::symlink_metadata(source)
        .with_context(|| format!("inspecting {}", source.display()))?;
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        bail!(
            "skill source must be a real directory: {}",
            source.display()
        );
    }
    ensure_real_dir(destination)?;
    for entry in std::fs::read_dir(source)? {
        let entry = entry?;
        let source_path = entry.path();
        let destination_path = destination.join(entry.file_name());
        let file_type = entry.file_type()?;
        if file_type.is_symlink() {
            bail!(
                "skill contains an unsupported symlink: {}",
                source_path.display()
            );
        }
        if file_type.is_dir() {
            copy_tree(&source_path, &destination_path)?;
        } else if file_type.is_file() {
            std::fs::copy(&source_path, &destination_path)
                .with_context(|| format!("copying {}", source_path.display()))?;
        }
    }
    Ok(())
}

pub fn sanitize_child_output(bytes: &[u8]) -> String {
    String::from_utf8_lossy(bytes)
        .chars()
        .map(|character| {
            if character == '\n' || character == '\t' || !character.is_control() {
                character
            } else {
                ''
            }
        })
        .collect()
}

fn home_dir() -> Result<PathBuf> {
    env::var_os("HOME")
        .map(PathBuf::from)
        .context("HOME is not configured")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(unix)]
    #[test]
    fn managed_state_rejects_symlinked_file_and_parent() {
        use std::os::unix::fs::symlink;

        let base = std::env::current_dir()
            .unwrap()
            .join("target/test-work/managed-state-symlink");
        let _ = std::fs::remove_dir_all(&base);
        std::fs::create_dir_all(base.join("real")).unwrap();
        std::fs::write(base.join("real/state.json"), "{}").unwrap();
        symlink(base.join("real/state.json"), base.join("linked.json")).unwrap();
        assert!(validate_managed_json_path(&base.join("linked.json")).is_err());
        symlink(base.join("real"), base.join("linked-parent")).unwrap();
        assert!(validate_managed_json_path(&base.join("linked-parent/state.json")).is_err());
        std::fs::remove_dir_all(&base).unwrap();
    }

    #[test]
    fn exclusive_json_write_acquires_once() {
        let path = std::env::current_dir()
            .unwrap()
            .join("target/test-work/exclusive-json/transaction.json");
        let _ = std::fs::remove_dir_all(path.parent().unwrap());
        write_json_exclusive_compact(&path, &serde_json::json!({ "phase": "prepared" })).unwrap();
        assert!(
            write_json_exclusive_compact(&path, &serde_json::json!({ "phase": "other" })).is_err()
        );
        assert_eq!(
            read_json::<serde_json::Value>(&path).unwrap()["phase"],
            "prepared"
        );
        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
    }

    #[test]
    fn child_output_strips_terminal_controls() {
        assert_eq!(
            sanitize_child_output(b"ok\n\x1b]8;;bad\x07link"),
            "ok\n�]8;;bad�link"
        );
    }
}