agr 0.1.0

agr — install agent artifacts from the public registry into a project
Documentation
//! `agr` — install agent artifacts from the public registry into a project.
//!
//! Read-only v1: `search`, `info`, `install`, `update`, `uninstall`. The
//! install manifest (fetched from the registry API) is the contract; files are
//! downloaded from their pinned source URLs and written to each ecosystem's
//! conventional path. Installs are recorded in [`lockfile`] (`agr.lock`), which
//! is also what `uninstall` reads — removal needs no network.

pub mod client;
pub mod lockfile;
pub mod ops;

/// Errors surfaced by the CLI.
#[derive(Debug, thiserror::Error)]
pub enum CliError {
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("registry API {status}: {body}")]
    Api { status: u16, body: String },
    /// A file download from the artifact's *source* host failed. Distinct from
    /// [`CliError::Api`] because the two name different systems: a 404 from
    /// `raw.githubusercontent.com` is not the registry being wrong.
    #[error("download failed ({status}) for {url}")]
    Download { url: String, status: u16 },
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("lockfile parse: {0}")]
    LockParse(#[from] toml::de::Error),
    #[error("lockfile encode: {0}")]
    LockEncode(#[from] toml::ser::Error),
    #[error("invalid handle '{0}' (expected @namespace/slug)")]
    BadHandle(String),
    #[error(
        "refusing to overwrite existing files (use --force):\n{}",
        .0.join("\n")
    )]
    WouldOverwrite(Vec<String>),
    #[error("{handle} [{target}] is not installed (no entry in agr.lock)")]
    NotInstalled { handle: String, target: String },
    #[error(
        "{handle} no longer matches what was installed; refusing to delete (use --force):\n{}",
        .details.join("\n")
    )]
    LocallyModified {
        handle: String,
        details: Vec<String>,
    },
    #[error("destination path '{0}' escapes the install directory")]
    UnsafePath(String),
}

/// Convenience alias.
pub type Result<T> = std::result::Result<T, CliError>;

/// Parse an `@ns/slug` (or `ns/slug`) handle into `(namespace, slug)`.
pub fn parse_handle(handle: &str) -> Result<(String, String)> {
    let h = handle.strip_prefix('@').unwrap_or(handle);
    match h.split_once('/') {
        Some((ns, slug)) if !ns.is_empty() && !slug.is_empty() && !slug.contains('/') => {
            Ok((ns.to_string(), slug.to_string()))
        }
        _ => Err(CliError::BadHandle(handle.to_string())),
    }
}

/// The line `agr publish` prints on success, derived from the gateway's
/// response body.
///
/// The gateway answers with the nested detail shape —
/// `{"artifact":{"namespace","slug",…},"latest_version":{"version",…},"scan"}`
/// (`saucebox_gateway::registry::rest::detail_json`) — and carries no top-level
/// `handle`, because `Artifact::handle()` is a method rather than a serialized
/// field. Reading flat `handle`/`version` therefore missed on every real
/// publish and fell through to dumping raw JSON.
///
/// The raw-JSON fallback is kept deliberately: a response shape this CLI does
/// not recognise should still show the operator what the server said.
pub fn publish_line(v: &serde_json::Value) -> String {
    let handle = match (
        v["artifact"]["namespace"].as_str(),
        v["artifact"]["slug"].as_str(),
    ) {
        (Some(ns), Some(slug)) => Some(format!("@{ns}/{slug}")),
        _ => None,
    };
    match (handle, v["latest_version"]["version"].as_str()) {
        (Some(h), Some(version)) => format!("published {h} @ {version}"),
        (Some(h), None) => format!("published {h}"),
        _ => serde_json::to_string_pretty(v).unwrap_or_default(),
    }
}