Skip to main content

registry_cli/
lib.rs

1//! `agr` — install agent artifacts from the public registry into a project.
2//!
3//! Read-only v1: `search`, `info`, `install`, `update`, `uninstall`. The
4//! install manifest (fetched from the registry API) is the contract; files are
5//! downloaded from their pinned source URLs and written to each ecosystem's
6//! conventional path. Installs are recorded in [`lockfile`] (`agr.lock`), which
7//! is also what `uninstall` reads — removal needs no network.
8
9pub mod client;
10pub mod lockfile;
11pub mod ops;
12
13/// Errors surfaced by the CLI.
14#[derive(Debug, thiserror::Error)]
15pub enum CliError {
16    #[error("http error: {0}")]
17    Http(#[from] reqwest::Error),
18    #[error("registry API {status}: {body}")]
19    Api { status: u16, body: String },
20    /// A file download from the artifact's *source* host failed. Distinct from
21    /// [`CliError::Api`] because the two name different systems: a 404 from
22    /// `raw.githubusercontent.com` is not the registry being wrong.
23    #[error("download failed ({status}) for {url}")]
24    Download { url: String, status: u16 },
25    #[error("io error: {0}")]
26    Io(#[from] std::io::Error),
27    #[error("lockfile parse: {0}")]
28    LockParse(#[from] toml::de::Error),
29    #[error("lockfile encode: {0}")]
30    LockEncode(#[from] toml::ser::Error),
31    #[error("invalid handle '{0}' (expected @namespace/slug)")]
32    BadHandle(String),
33    #[error(
34        "refusing to overwrite existing files (use --force):\n{}",
35        .0.join("\n")
36    )]
37    WouldOverwrite(Vec<String>),
38    #[error("{handle} [{target}] is not installed (no entry in agr.lock)")]
39    NotInstalled { handle: String, target: String },
40    #[error(
41        "{handle} no longer matches what was installed; refusing to delete (use --force):\n{}",
42        .details.join("\n")
43    )]
44    LocallyModified {
45        handle: String,
46        details: Vec<String>,
47    },
48    #[error("destination path '{0}' escapes the install directory")]
49    UnsafePath(String),
50}
51
52/// Convenience alias.
53pub type Result<T> = std::result::Result<T, CliError>;
54
55/// Parse an `@ns/slug` (or `ns/slug`) handle into `(namespace, slug)`.
56pub fn parse_handle(handle: &str) -> Result<(String, String)> {
57    let h = handle.strip_prefix('@').unwrap_or(handle);
58    match h.split_once('/') {
59        Some((ns, slug)) if !ns.is_empty() && !slug.is_empty() && !slug.contains('/') => {
60            Ok((ns.to_string(), slug.to_string()))
61        }
62        _ => Err(CliError::BadHandle(handle.to_string())),
63    }
64}
65
66/// The line `agr publish` prints on success, derived from the gateway's
67/// response body.
68///
69/// The gateway answers with the nested detail shape —
70/// `{"artifact":{"namespace","slug",…},"latest_version":{"version",…},"scan"}`
71/// (`saucebox_gateway::registry::rest::detail_json`) — and carries no top-level
72/// `handle`, because `Artifact::handle()` is a method rather than a serialized
73/// field. Reading flat `handle`/`version` therefore missed on every real
74/// publish and fell through to dumping raw JSON.
75///
76/// The raw-JSON fallback is kept deliberately: a response shape this CLI does
77/// not recognise should still show the operator what the server said.
78pub fn publish_line(v: &serde_json::Value) -> String {
79    let handle = match (
80        v["artifact"]["namespace"].as_str(),
81        v["artifact"]["slug"].as_str(),
82    ) {
83        (Some(ns), Some(slug)) => Some(format!("@{ns}/{slug}")),
84        _ => None,
85    };
86    match (handle, v["latest_version"]["version"].as_str()) {
87        (Some(h), Some(version)) => format!("published {h} @ {version}"),
88        (Some(h), None) => format!("published {h}"),
89        _ => serde_json::to_string_pretty(v).unwrap_or_default(),
90    }
91}