agr 0.1.0

agr — install agent artifacts from the public registry into a project
Documentation
//! Install / update / uninstall operations over the registry client and
//! lockfile.

use std::path::{Component, Path, PathBuf};

use sha2::{Digest, Sha256};

use crate::client::{Manifest, RegistryClient};
use crate::lockfile::{LockEntry, Lockfile};
use crate::{CliError, Result, parse_handle};

/// Outcome of an install.
#[derive(Debug)]
pub struct InstallSummary {
    pub handle: String,
    pub target: String,
    pub version: String,
    pub written: Vec<String>,
    /// True when the artifact was already installed at this version (no writes).
    pub skipped: bool,
    /// True when the manifest is not pinned to an immutable commit sha.
    pub unpinned: bool,
}

/// Install `handle` for `target` under `dir`, recording the result in the
/// lockfile at `lock_path`. Refuses to overwrite pre-existing files unless
/// `force`; a re-install at the same version is a no-op.
pub async fn install(
    client: &RegistryClient,
    handle: &str,
    target: &str,
    dir: &Path,
    force: bool,
    lock_path: &Path,
) -> Result<InstallSummary> {
    let (ns, slug) = parse_handle(handle)?;
    let manifest = client.manifest(&ns, &slug, target).await?;
    let mut lock = Lockfile::load(lock_path)?;

    let dests: Vec<PathBuf> = manifest
        .files
        .iter()
        .map(|f| safe_dest(dir, &f.dest_path))
        .collect::<Result<_>>()?;
    let all_exist = !dests.is_empty() && dests.iter().all(|p| p.exists());
    let up_to_date = lock
        .get(&manifest.handle, target)
        .is_some_and(|e| e.version == manifest.version)
        && all_exist;

    if up_to_date && !force {
        return Ok(InstallSummary {
            handle: manifest.handle,
            target: target.to_string(),
            version: manifest.version,
            written: Vec::new(),
            skipped: true,
            unpinned: !manifest.pinned,
        });
    }

    if !force {
        let conflicts: Vec<String> = dests
            .iter()
            .filter(|p| p.exists())
            .map(|p| p.display().to_string())
            .collect();
        if !conflicts.is_empty() {
            return Err(CliError::WouldOverwrite(conflicts));
        }
    }

    let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
    lock.upsert(LockEntry {
        handle: manifest.handle.clone(),
        version: manifest.version.clone(),
        target: target.to_string(),
        content_hash,
        files: written.clone(),
    });
    lock.save(lock_path)?;

    Ok(InstallSummary {
        handle: manifest.handle,
        target: target.to_string(),
        version: manifest.version,
        written,
        skipped: false,
        unpinned: !manifest.pinned,
    })
}

/// A single pending/applied update.
#[derive(Debug)]
pub struct UpdateChange {
    pub handle: String,
    pub target: String,
    pub from: String,
    pub to: String,
}

/// An update that was available but not written.
#[derive(Debug)]
pub struct UpdateSkip {
    pub handle: String,
    pub target: String,
    pub reason: String,
}

/// Result of an update run.
#[derive(Debug)]
pub struct UpdateReport {
    pub changes: Vec<UpdateChange>,
    /// Entries whose new version was not written because the installed files
    /// had drifted from the lockfile.
    pub skipped: Vec<UpdateSkip>,
    pub applied: bool,
}

/// Re-resolve every lockfile entry. With `apply`, download and rewrite changed
/// artifacts (overwriting their own files) and update the lockfile.
///
/// An artifact whose installed files no longer match the lockfile is reported
/// and left alone unless `force`. This matters most for the targets that write
/// to a fixed project path — `codex` owns `AGENTS.md` and `copilot` owns
/// `.github/copilot-instructions.md` — where an overwrite would take a file the
/// project also edits by hand.
pub async fn update(
    client: &RegistryClient,
    dir: &Path,
    lock_path: &Path,
    apply: bool,
    force: bool,
) -> Result<UpdateReport> {
    let mut lock = Lockfile::load(lock_path)?;
    let entries = lock.artifacts.clone();
    let mut changes = Vec::new();
    let mut skipped = Vec::new();
    let mut wrote_any = false;

    for entry in &entries {
        let (ns, slug) = parse_handle(&entry.handle)?;
        let manifest = client.manifest(&ns, &slug, &entry.target).await?;
        if manifest.version == entry.version {
            continue;
        }
        changes.push(UpdateChange {
            handle: entry.handle.clone(),
            target: entry.target.clone(),
            from: entry.version.clone(),
            to: manifest.version.clone(),
        });
        if !apply {
            continue;
        }
        if !force {
            let paths: Vec<(String, PathBuf)> = entry
                .files
                .iter()
                .map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
                .collect::<Result<_>>()?;
            match verify_unmodified(entry, &paths) {
                Ok(()) => {}
                Err(CliError::LocallyModified { details, .. }) => {
                    skipped.push(UpdateSkip {
                        handle: entry.handle.clone(),
                        target: entry.target.clone(),
                        reason: details.join("; ").trim().to_string(),
                    });
                    continue;
                }
                Err(e) => return Err(e),
            }
        }
        let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
        lock.upsert(LockEntry {
            handle: manifest.handle.clone(),
            version: manifest.version.clone(),
            target: entry.target.clone(),
            content_hash,
            files: written,
        });
        wrote_any = true;
    }

    if wrote_any {
        lock.save(lock_path)?;
    }

    Ok(UpdateReport {
        changes,
        skipped,
        applied: apply,
    })
}

/// Outcome of an uninstall.
#[derive(Debug)]
pub struct UninstallSummary {
    pub handle: String,
    pub target: String,
    pub version: String,
    /// Files deleted from disk.
    pub removed: Vec<String>,
    /// Files the lockfile recorded that were already gone.
    pub missing: Vec<String>,
}

/// Remove an installed artifact's files and its lockfile entry.
///
/// Offline: `agr.lock` records everything needed, so removal never contacts the
/// registry — an artifact stays uninstallable after it is unpublished.
///
/// Only the paths the lockfile recorded are touched, never a glob of the
/// install directory. Without `force` the install must still be byte-identical
/// to what was written, so local edits are never silently discarded.
pub fn uninstall(
    dir: &Path,
    lock_path: &Path,
    handle: &str,
    target: &str,
    force: bool,
) -> Result<UninstallSummary> {
    let (ns, slug) = parse_handle(handle)?;
    let handle = format!("@{ns}/{slug}");
    let mut lock = Lockfile::load(lock_path)?;
    let entry = lock
        .get(&handle, target)
        .ok_or_else(|| CliError::NotInstalled {
            handle: handle.clone(),
            target: target.to_string(),
        })?
        .clone();

    let paths: Vec<(String, PathBuf)> = entry
        .files
        .iter()
        .map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
        .collect::<Result<_>>()?;

    if !force {
        verify_unmodified(&entry, &paths)?;
    }

    let mut removed = Vec::new();
    let mut missing = Vec::new();
    for (rel, path) in &paths {
        match std::fs::remove_file(path) {
            Ok(()) => removed.push(rel.clone()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => missing.push(rel.clone()),
            Err(e) => return Err(e.into()),
        }
        prune_empty_dirs(dir, path);
    }

    lock.remove(&handle, target);
    lock.save(lock_path)?;

    Ok(UninstallSummary {
        handle,
        target: target.to_string(),
        version: entry.version,
        removed,
        missing,
    })
}

/// Refuse to delete an install that no longer matches what was recorded: every
/// recorded file must still be present, and the digest over them must equal the
/// lockfile's.
fn verify_unmodified(entry: &LockEntry, paths: &[(String, PathBuf)]) -> Result<()> {
    let mut hasher = Sha256::new();
    let mut details = Vec::new();
    for (rel, path) in paths {
        match std::fs::read(path) {
            Ok(bytes) => hasher.update(&bytes),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                details.push(format!("  {rel} (already deleted)"));
            }
            Err(e) => return Err(e.into()),
        }
    }
    if details.is_empty() {
        if hex::encode(hasher.finalize()) == entry.content_hash {
            return Ok(());
        }
        details.push("  contents differ from the installed version".to_string());
    }
    Err(CliError::LocallyModified {
        handle: entry.handle.clone(),
        details,
    })
}

/// Remove directories left empty by a deletion, walking up toward — but never
/// past or including — the install root. `remove_dir` fails on a non-empty
/// directory, which is what stops the walk at the first directory holding
/// anything else.
fn prune_empty_dirs(root: &Path, file: &Path) {
    let mut current = file.parent();
    while let Some(dir) = current {
        if dir == root || !dir.starts_with(root) || std::fs::remove_dir(dir).is_err() {
            return;
        }
        current = dir.parent();
    }
}

/// Resolve a manifest destination under `dir`, rejecting anything that escapes
/// it. Destination paths arrive from the registry, so a hostile or malformed
/// manifest must not be able to reach outside the project — on install to
/// write, and on uninstall to delete.
fn safe_dest(dir: &Path, rel: &str) -> Result<PathBuf> {
    let path = Path::new(rel);
    let escapes = path.components().any(|c| {
        matches!(
            c,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    });
    if rel.is_empty() || escapes {
        return Err(CliError::UnsafePath(rel.to_string()));
    }
    Ok(dir.join(path))
}

/// Download every file in `manifest` and write it under `dir`, returning the
/// written destination paths and a sha256 over their concatenated bytes.
///
/// Files are processed in destination order so the digest can be recomputed
/// from the lockfile's (sorted) `files` alone — which is how `uninstall` tells
/// an untouched install from an edited one.
async fn apply_manifest(
    client: &RegistryClient,
    manifest: &Manifest,
    dir: &Path,
) -> Result<(Vec<String>, String)> {
    let mut files: Vec<&crate::client::ManifestFile> = manifest.files.iter().collect();
    files.sort_by(|a, b| a.dest_path.cmp(&b.dest_path));

    let mut hasher = Sha256::new();
    let mut written = Vec::new();
    for file in files {
        let path = safe_dest(dir, &file.dest_path)?;
        let bytes = client.fetch_file(&file.source_url).await?;
        hasher.update(&bytes);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&path, &bytes)?;
        written.push(file.dest_path.clone());
    }
    Ok((written, hex::encode(hasher.finalize())))
}