agr 0.1.0

agr — install agent artifacts from the public registry into a project
Documentation
//! `agr` — the registry CLI binary.

use std::path::PathBuf;
use std::process::ExitCode;

use clap::{Parser, Subcommand};

use registry_cli::client::{PublishReq, RegistryClient};
use registry_cli::{ops, parse_handle, publish_line};

/// Where `agr` talks to when nothing overrides it.
///
/// The public registry, not localhost: this binary is installed with
/// `cargo install agr` by people who have no gateway of their own, and
/// `agr search` is the first thing they run. Point it elsewhere with
/// `--registry` or `AGR_REGISTRY_URL` when running against a local stack.
///
/// A published version pins this forever — anyone who installed 0.1.0 keeps
/// talking to this host until they reinstall — so it names a domain we control
/// rather than the generated run.app URL, which is tied to one service.
const DEFAULT_REGISTRY: &str = "https://api.saucebox.sirhco.codes";

#[derive(Parser)]
#[command(
    name = "agr",
    about = "Install agent artifacts from the public registry"
)]
struct Cli {
    /// Registry base URL (else `AGR_REGISTRY_URL`).
    #[arg(long, env = "AGR_REGISTRY_URL", default_value = DEFAULT_REGISTRY, global = true)]
    registry: String,

    /// Bearer token for publish/unpublish (else `AGR_TOKEN`).
    #[arg(long, env = "AGR_TOKEN", global = true)]
    token: Option<String>,

    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Search the registry.
    Search {
        query: String,
        #[arg(long)]
        r#type: Option<String>,
        #[arg(long)]
        ecosystem: Option<String>,
    },
    /// Show an artifact's details.
    Info { handle: String },
    /// Install an artifact into the current project.
    Install {
        handle: String,
        #[arg(long)]
        target: String,
        /// Install root (default: current directory).
        #[arg(long)]
        dir: Option<PathBuf>,
        /// Overwrite existing files.
        #[arg(long)]
        force: bool,
    },
    /// Remove an installed artifact's files and its lockfile entry.
    Uninstall {
        handle: String,
        #[arg(long)]
        target: String,
        /// Install root (default: current directory).
        #[arg(long)]
        dir: Option<PathBuf>,
        /// Delete even if the files changed after install.
        #[arg(long)]
        force: bool,
    },
    /// Re-resolve the lockfile and show (or apply) updates.
    Update {
        #[arg(long)]
        dir: Option<PathBuf>,
        /// Apply the changes instead of only listing them.
        #[arg(long)]
        yes: bool,
        /// Overwrite artifacts whose files changed after install.
        #[arg(long)]
        force: bool,
    },
    /// Publish an artifact to the registry (requires --token / AGR_TOKEN).
    Publish {
        handle: String,
        #[arg(long)]
        repo: String,
        #[arg(long)]
        path: String,
        #[arg(long = "type")]
        r#type: String,
        #[arg(long = "ecosystem")]
        ecosystems: Vec<String>,
        #[arg(long)]
        visibility: Option<String>,
        #[arg(long)]
        description: Option<String>,
    },
    /// Remove an artifact from the registry (requires --token / AGR_TOKEN).
    Unpublish { handle: String },
}

#[tokio::main]
async fn main() -> ExitCode {
    match run().await {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::FAILURE
        }
    }
}

async fn run() -> registry_cli::Result<()> {
    let cli = Cli::parse();
    let client = match cli.token {
        Some(t) => RegistryClient::new(&cli.registry).with_token(t),
        None => RegistryClient::new(&cli.registry),
    };

    match cli.command {
        Command::Search {
            query,
            r#type,
            ecosystem,
        } => {
            let results = client
                .search(Some(&query), r#type.as_deref(), ecosystem.as_deref())
                .await?;
            if results.is_empty() {
                println!("no artifacts found");
            }
            for a in results {
                println!(
                    "{:<32} {:<12} ★{:<5} {}",
                    a.handle(),
                    a.artifact_type,
                    a.stars,
                    truncate(&a.description, 60)
                );
            }
        }
        Command::Info { handle } => {
            let (ns, slug) = parse_handle(&handle)?;
            let detail = client.info(&ns, &slug).await?;
            println!(
                "{}",
                serde_json::to_string_pretty(&detail).unwrap_or_default()
            );
        }
        Command::Install {
            handle,
            target,
            dir,
            force,
        } => {
            let dir = dir.unwrap_or_else(|| PathBuf::from("."));
            let lock_path = dir.join("agr.lock");
            let summary = ops::install(&client, &handle, &target, &dir, force, &lock_path).await?;
            if summary.skipped {
                println!(
                    "{} [{}] already at {} — nothing to do",
                    summary.handle, summary.target, summary.version
                );
            } else {
                println!(
                    "installed {} [{}] {} ({} file{})",
                    summary.handle,
                    summary.target,
                    summary.version,
                    summary.written.len(),
                    if summary.written.len() == 1 { "" } else { "s" }
                );
                for f in &summary.written {
                    println!("  + {f}");
                }
            }
            if summary.unpinned {
                eprintln!(
                    "warning: manifest is not pinned to an immutable commit — \
                     contents may change upstream"
                );
            }
        }
        Command::Uninstall {
            handle,
            target,
            dir,
            force,
        } => {
            let dir = dir.unwrap_or_else(|| PathBuf::from("."));
            let lock_path = dir.join("agr.lock");
            let summary = ops::uninstall(&dir, &lock_path, &handle, &target, force)?;
            println!(
                "removed {} [{}] {} ({} file{})",
                summary.handle,
                summary.target,
                summary.version,
                summary.removed.len(),
                if summary.removed.len() == 1 { "" } else { "s" }
            );
            for f in &summary.removed {
                println!("  - {f}");
            }
            for f in &summary.missing {
                eprintln!("warning: {f} was already gone");
            }
        }
        Command::Update { dir, yes, force } => {
            let dir = dir.unwrap_or_else(|| PathBuf::from("."));
            let lock_path = dir.join("agr.lock");
            let report = ops::update(&client, &dir, &lock_path, yes, force).await?;
            if report.changes.is_empty() {
                println!("everything up to date");
            } else {
                for c in &report.changes {
                    println!("{} [{}] {}{}", c.handle, c.target, c.from, c.to);
                }
                if report.applied {
                    println!(
                        "applied {} update(s)",
                        report.changes.len() - report.skipped.len()
                    );
                } else {
                    println!("run with --yes to apply");
                }
            }
            for s in &report.skipped {
                eprintln!(
                    "skipped {} [{}]: {} (use --force to overwrite)",
                    s.handle, s.target, s.reason
                );
            }
        }
        Command::Publish {
            handle,
            repo,
            path,
            r#type,
            ecosystems,
            visibility,
            description,
        } => {
            let req = PublishReq {
                handle,
                r#type,
                ecosystems,
                source_repo: repo,
                source_path: path,
                visibility,
                description,
            };
            let artifact = client.publish(&req).await?;
            println!("{}", publish_line(&artifact));
        }
        Command::Unpublish { handle } => {
            let (ns, slug) = parse_handle(&handle)?;
            client.unpublish(&ns, &slug).await?;
            println!("unpublished @{ns}/{slug}");
        }
    }
    Ok(())
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    s.chars().take(max.saturating_sub(1)).chain(['']).collect()
}