skillinstaller 0.1.0

Install one .skill payload across multiple AI coding providers with deterministic project/user targets.
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use walkdir::WalkDir;

use crate::error::{InstallerError, Result};
use crate::parser::{parse_skill, resolve_local_skill_root};
use crate::providers::{normalize_providers, resolve_provider_dir};
use crate::types::{
    EmbeddedSkill, InstallMethod, InstallRequest, InstallResult, InstallTarget, ProviderId, Scope,
    SkillSource,
};

pub fn resolve_install_target(
    requested_provider: ProviderId,
    scope: Scope,
    project_root: Option<&Path>,
) -> Result<InstallTarget> {
    let target_provider = if crate::providers::is_agents_provider(requested_provider) {
        ProviderId::Universal
    } else {
        requested_provider
    };

    let target_dir = resolve_provider_dir(target_provider, scope, project_root)?;
    Ok(InstallTarget {
        requested_provider,
        target_provider,
        target_dir,
    })
}

pub fn print_install_result(result: &InstallResult) {
    println!("installed skill: {}", result.skill_name);

    for target in &result.installed_targets {
        println!(
            "  {} -> {} ({})",
            target.requested_provider.as_str(),
            target.target_provider.as_str(),
            target.target_dir.display()
        );
    }

    if !result.warnings.is_empty() {
        println!("warnings:");
        for w in &result.warnings {
            println!("  - {w}");
        }
    }
}

pub fn install(request: InstallRequest) -> Result<InstallResult> {
    match request.method {
        InstallMethod::Copy => install_copy(request),
        InstallMethod::Symlink => install_symlink(request),
    }
}

pub fn find_existing_destinations(
    source: &SkillSource,
    providers: &[ProviderId],
    scope: Scope,
    project_root: Option<&Path>,
) -> Result<Vec<PathBuf>> {
    let parsed = parse_skill(source)?;
    let (targets, _) = normalize_providers(providers);

    let mut existing = Vec::new();
    let mut seen = HashSet::new();

    for provider in targets {
        let target = resolve_install_target(provider, scope, project_root)?;
        let destination = target.target_dir.join(&parsed.name);
        if seen.insert(destination.clone()) && destination.exists() {
            existing.push(destination);
        }
    }

    Ok(existing)
}

fn install_copy(request: InstallRequest) -> Result<InstallResult> {
    let parsed = parse_skill(&request.source)?;
    let (providers, normalized_providers) = normalize_providers(&request.providers);

    let mut installed_targets = Vec::new();
    let mut skipped_duplicates = Vec::new();
    let mut warnings = Vec::new();
    let mut seen_paths = HashSet::new();

    for provider in providers {
        let target =
            resolve_install_target(provider, request.scope, request.project_root.as_deref())?;
        let destination = target.target_dir.join(&parsed.name);

        if !seen_paths.insert(destination.clone()) {
            skipped_duplicates.push(destination);
            continue;
        }

        if destination.exists() && !request.force {
            return Err(InstallerError::AlreadyExists { path: destination });
        }

        copy_source_to_destination(&request.source, &destination)?;

        installed_targets.push(InstallTarget {
            requested_provider: provider,
            target_provider: target.target_provider,
            target_dir: destination,
        });
    }

    for (from, to) in &normalized_providers {
        warnings.push(format!(
            "provider '{}' normalized to '{}' shared .agents target",
            from.as_str(),
            to.as_str()
        ));
    }

    Ok(InstallResult {
        skill_name: parsed.name,
        installed_targets,
        normalized_providers,
        skipped_duplicates,
        warnings,
    })
}

fn install_symlink(request: InstallRequest) -> Result<InstallResult> {
    let parsed = parse_skill(&request.source)?;
    let universal_target = resolve_install_target(
        ProviderId::Universal,
        request.scope,
        request.project_root.as_deref(),
    )?;
    let universal_destination = universal_target.target_dir.join(&parsed.name);
    let (providers, normalized_providers) = normalize_providers(&request.providers);

    let mut installed_targets = Vec::new();
    let mut skipped_duplicates = Vec::new();
    let mut warnings = Vec::new();
    let mut seen_paths = HashSet::new();

    if universal_destination.exists() {
        if !request.force {
            return Err(InstallerError::AlreadyExists {
                path: universal_destination.clone(),
            });
        }
        remove_path(&universal_destination)?;
    }

    copy_source_to_destination(&request.source, &universal_destination)?;

    seen_paths.insert(universal_destination.clone());

    for provider in providers {
        let target =
            resolve_install_target(provider, request.scope, request.project_root.as_deref())?;
        let destination = target.target_dir.join(&parsed.name);

        if destination == universal_destination {
            installed_targets.push(InstallTarget {
                requested_provider: provider,
                target_provider: target.target_provider,
                target_dir: destination,
            });
            continue;
        }

        if !seen_paths.insert(destination.clone()) {
            skipped_duplicates.push(destination);
            continue;
        }

        if destination.exists() {
            if !request.force {
                return Err(InstallerError::AlreadyExists { path: destination });
            }
            remove_path(&destination)?;
        }

        if let Some(parent) = destination.parent() {
            fs::create_dir_all(parent).map_err(|err| InstallerError::IoError {
                path: parent.to_path_buf(),
                message: err.to_string(),
            })?;
        }

        create_dir_symlink(&universal_destination, &destination)?;

        installed_targets.push(InstallTarget {
            requested_provider: provider,
            target_provider: target.target_provider,
            target_dir: destination,
        });
    }

    for (from, to) in &normalized_providers {
        warnings.push(format!(
            "provider '{}' normalized to '{}' shared .agents target",
            from.as_str(),
            to.as_str()
        ));
    }

    Ok(InstallResult {
        skill_name: parsed.name,
        installed_targets,
        normalized_providers,
        skipped_duplicates,
        warnings,
    })
}

fn remove_path(path: &Path) -> Result<()> {
    let metadata = fs::symlink_metadata(path).map_err(|err| InstallerError::IoError {
        path: path.to_path_buf(),
        message: err.to_string(),
    })?;
    if metadata.file_type().is_symlink() || metadata.is_file() {
        fs::remove_file(path).map_err(|err| InstallerError::IoError {
            path: path.to_path_buf(),
            message: err.to_string(),
        })?;
    } else if metadata.is_dir() {
        fs::remove_dir_all(path).map_err(|err| InstallerError::IoError {
            path: path.to_path_buf(),
            message: err.to_string(),
        })?;
    }
    Ok(())
}

#[cfg(unix)]
fn create_dir_symlink(source: &Path, destination: &Path) -> Result<()> {
    std::os::unix::fs::symlink(source, destination).map_err(|err| InstallerError::IoError {
        path: destination.to_path_buf(),
        message: format!(
            "failed to create symlink '{}' -> '{}': {err}",
            destination.display(),
            source.display()
        ),
    })
}

#[cfg(windows)]
fn create_dir_symlink(source: &Path, destination: &Path) -> Result<()> {
    std::os::windows::fs::symlink_dir(source, destination).map_err(|err| InstallerError::IoError {
        path: destination.to_path_buf(),
        message: format!(
            "failed to create symlink '{}' -> '{}': {err}",
            destination.display(),
            source.display()
        ),
    })
}

fn copy_source_to_destination(source: &SkillSource, destination: &Path) -> Result<()> {
    let parent = destination
        .parent()
        .ok_or_else(|| InstallerError::IoError {
            path: destination.to_path_buf(),
            message: "destination has no parent".to_string(),
        })?;

    fs::create_dir_all(parent).map_err(|err| InstallerError::IoError {
        path: parent.to_path_buf(),
        message: err.to_string(),
    })?;

    let staging = parent.join(format!(
        ".{}.tmp-{}",
        destination
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("skill"),
        std::process::id()
    ));

    if staging.exists() {
        fs::remove_dir_all(&staging).map_err(|err| InstallerError::IoError {
            path: staging.clone(),
            message: err.to_string(),
        })?;
    }

    fs::create_dir_all(&staging).map_err(|err| InstallerError::IoError {
        path: staging.clone(),
        message: err.to_string(),
    })?;

    match source {
        SkillSource::LocalPath(path) => {
            let root = resolve_local_skill_root(path)?;
            copy_dir_recursive(&root, &staging)?;
        }
        SkillSource::Embedded(embedded) => {
            write_embedded(embedded, &staging)?;
        }
    }

    if destination.exists() {
        fs::remove_dir_all(destination).map_err(|err| InstallerError::IoError {
            path: destination.to_path_buf(),
            message: err.to_string(),
        })?;
    }

    fs::rename(&staging, destination).map_err(|err| InstallerError::IoError {
        path: destination.to_path_buf(),
        message: err.to_string(),
    })?;

    Ok(())
}

fn write_embedded(embedded: &EmbeddedSkill, destination: &Path) -> Result<()> {
    fs::write(destination.join("SKILL.md"), embedded.skill_md.as_bytes()).map_err(|err| {
        InstallerError::IoError {
            path: destination.join("SKILL.md"),
            message: err.to_string(),
        }
    })?;

    for (relative_path, bytes) in &embedded.files {
        if relative_path
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            return Err(InstallerError::InvalidSource {
                path: relative_path.clone(),
            });
        }

        let file_path = destination.join(relative_path);
        if let Some(parent) = file_path.parent() {
            fs::create_dir_all(parent).map_err(|err| InstallerError::IoError {
                path: parent.to_path_buf(),
                message: err.to_string(),
            })?;
        }
        fs::write(&file_path, bytes).map_err(|err| InstallerError::IoError {
            path: file_path,
            message: err.to_string(),
        })?;
    }

    Ok(())
}

fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
    for entry in WalkDir::new(source) {
        let entry = entry.map_err(|err| InstallerError::IoError {
            path: source.to_path_buf(),
            message: err.to_string(),
        })?;

        let relative =
            entry
                .path()
                .strip_prefix(source)
                .map_err(|err| InstallerError::IoError {
                    path: entry.path().to_path_buf(),
                    message: err.to_string(),
                })?;

        if relative.as_os_str().is_empty() {
            continue;
        }

        let target = destination.join(relative);
        if entry.file_type().is_dir() {
            fs::create_dir_all(&target).map_err(|err| InstallerError::IoError {
                path: target,
                message: err.to_string(),
            })?;
        } else {
            if let Some(parent) = target.parent() {
                fs::create_dir_all(parent).map_err(|err| InstallerError::IoError {
                    path: parent.to_path_buf(),
                    message: err.to_string(),
                })?;
            }
            fs::copy(entry.path(), &target).map_err(|err| InstallerError::IoError {
                path: target,
                message: err.to_string(),
            })?;
        }
    }

    Ok(())
}