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};
const DEFAULT_REGISTRY: &str = "https://api.saucebox.sirhco.codes";
#[derive(Parser)]
#[command(
name = "agr",
version,
about = "Install agent artifacts from the public registry"
)]
struct Cli {
#[arg(long, env = "AGR_REGISTRY_URL", default_value = DEFAULT_REGISTRY, global = true)]
registry: String,
#[arg(long, env = "AGR_TOKEN", global = true)]
token: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Search {
query: String,
#[arg(long)]
r#type: Option<String>,
#[arg(long)]
ecosystem: Option<String>,
},
Info { handle: String },
Install {
handle: String,
#[arg(long)]
target: String,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long)]
force: bool,
},
Uninstall {
handle: String,
#[arg(long)]
target: String,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long)]
force: bool,
},
Update {
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long)]
yes: bool,
#[arg(long)]
force: bool,
},
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>,
},
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()
}