1use std::ffi::OsString;
2use std::fs::File;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use bzip2::read::BzDecoder;
8use flate2::read::GzDecoder;
9use tokio::fs;
10use tokio::process::Command;
11use tokio::task;
12use zip::ZipArchive;
13
14use crate::registry::{
15 BinaryTarget, NpxDistribution, Platform, Registry, RegistryAgent, UvxDistribution,
16 fetch_registry,
17};
18
19pub async fn install_agent(agent_id: &str) -> Result<InstallOutcome> {
24 let registry = fetch_registry().await?;
25 let agent = registry.get_agent(agent_id)?;
26
27 install_from_registry(®istry, agent).await
28}
29
30pub async fn install_from_registry(
36 _registry: &Registry,
37 agent: &RegistryAgent,
38) -> Result<InstallOutcome> {
39 if let Some(binary) = &agent.distribution.binary {
40 let platform = Platform::current()?;
41 if let Some(target) = binary.for_platform(platform) {
42 return install_binary(agent, target).await;
43 }
44 }
45
46 if let Some(npx) = &agent.distribution.npx {
47 return install_npx(agent, npx).await;
48 }
49
50 if let Some(uvx) = &agent.distribution.uvx {
51 return install_uvx(agent, uvx).await;
52 }
53
54 Err(anyhow!(
55 "agent \"{}\" does not have an installable distribution",
56 agent.id
57 ))
58}
59
60async fn install_npx(
61 agent: &RegistryAgent,
62 distribution: &NpxDistribution,
63) -> Result<InstallOutcome> {
64 run_command(
65 "npm",
66 ["i", "-g", distribution.package.as_str()],
67 &format!("npm package {}", distribution.package),
68 )
69 .await?;
70
71 Ok(InstallOutcome::PackageManager {
72 agent_id: agent.id.clone(),
73 method: InstallMethod::Npx,
74 package: distribution.package.clone(),
75 })
76}
77
78async fn install_uvx(
79 agent: &RegistryAgent,
80 distribution: &UvxDistribution,
81) -> Result<InstallOutcome> {
82 run_command(
83 "uv",
84 ["tool", "install", distribution.package.as_str()],
85 &format!("uv package {}", distribution.package),
86 )
87 .await?;
88
89 Ok(InstallOutcome::PackageManager {
90 agent_id: agent.id.clone(),
91 method: InstallMethod::Uvx,
92 package: distribution.package.clone(),
93 })
94}
95
96async fn install_binary(agent: &RegistryAgent, target: &BinaryTarget) -> Result<InstallOutcome> {
97 let temp_dir = task::spawn_blocking(tempfile::tempdir)
98 .await
99 .context("blocking task failed while creating temporary directory")?
100 .context("failed to create temporary directory")?;
101 let archive_path = download_archive(target, temp_dir.path()).await?;
102 let extracted_dir = temp_dir.path().join("extracted");
103 fs::create_dir_all(&extracted_dir)
104 .await
105 .with_context(|| format!("failed to create {}", extracted_dir.display()))?;
106 extract_archive(archive_path, extracted_dir.clone()).await?;
107
108 let source_path = resolve_cmd_path(&extracted_dir, &target.cmd);
109 let source_metadata = fs::metadata(&source_path).await;
110 if source_metadata
111 .as_ref()
112 .map(|metadata| !metadata.is_file())
113 .unwrap_or(true)
114 {
115 bail!(
116 "downloaded {}, but could not find \"{}\" at {}",
117 target.archive,
118 target.cmd,
119 source_path.display()
120 );
121 }
122
123 let install_dir = user_install_dir()?;
124 fs::create_dir_all(&install_dir)
125 .await
126 .with_context(|| format!("failed to create {}", install_dir.display()))?;
127 let file_name = source_path
128 .file_name()
129 .ok_or_else(|| anyhow!("invalid binary command path: {}", target.cmd))?;
130 let destination = install_dir.join(file_name);
131 fs::copy(&source_path, &destination)
132 .await
133 .with_context(|| {
134 format!(
135 "failed to copy {} to {}",
136 source_path.display(),
137 destination.display()
138 )
139 })?;
140 make_executable(&destination).await?;
141
142 Ok(InstallOutcome::Binary {
143 agent_id: agent.id.clone(),
144 path: destination,
145 })
146}
147
148pub(crate) async fn download_archive(target: &BinaryTarget, temp_dir: &Path) -> Result<PathBuf> {
149 let url = reqwest::Url::parse(&target.archive)
150 .with_context(|| format!("invalid archive URL: {}", target.archive))?;
151 let archive_name = url
152 .path_segments()
153 .and_then(|mut segments| segments.next_back())
154 .filter(|segment| !segment.is_empty())
155 .unwrap_or("download.bin");
156 let destination = temp_dir.join(archive_name);
157
158 let response = reqwest::get(url)
159 .await
160 .with_context(|| format!("failed to download archive from {}", target.archive))?;
161 let response = response
162 .error_for_status()
163 .with_context(|| format!("failed to download archive from {}", target.archive))?;
164 let bytes = response
165 .bytes()
166 .await
167 .with_context(|| format!("failed to read archive response from {}", target.archive))?;
168 fs::write(&destination, bytes.as_ref())
169 .await
170 .with_context(|| {
171 format!(
172 "failed to write downloaded archive to {}",
173 destination.display()
174 )
175 })?;
176 Ok(destination)
177}
178
179pub(crate) async fn extract_archive(archive_path: PathBuf, destination: PathBuf) -> Result<()> {
180 task::spawn_blocking(move || extract_archive_blocking(&archive_path, &destination))
181 .await
182 .context("blocking task failed while extracting archive")?
183}
184
185fn extract_archive_blocking(archive_path: &Path, destination: &Path) -> Result<()> {
186 let file_name = archive_path
187 .file_name()
188 .and_then(|name| name.to_str())
189 .unwrap_or_default()
190 .to_ascii_lowercase();
191
192 if file_name.ends_with(".zip") {
193 return extract_zip(archive_path, destination);
194 }
195
196 if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
197 let file = File::open(archive_path)
198 .with_context(|| format!("failed to open archive {}", archive_path.display()))?;
199 let decoder = GzDecoder::new(file);
200 let mut archive = tar::Archive::new(decoder);
201 archive
202 .unpack(destination)
203 .with_context(|| format!("failed to unpack archive into {}", destination.display()))?;
204 return Ok(());
205 }
206
207 if file_name.ends_with(".tar.bz2") || file_name.ends_with(".tbz2") {
208 let file = File::open(archive_path)
209 .with_context(|| format!("failed to open archive {}", archive_path.display()))?;
210 let decoder = BzDecoder::new(file);
211 let mut archive = tar::Archive::new(decoder);
212 archive
213 .unpack(destination)
214 .with_context(|| format!("failed to unpack archive into {}", destination.display()))?;
215 return Ok(());
216 }
217
218 let file_name = archive_path
219 .file_name()
220 .ok_or_else(|| anyhow!("unsupported archive format for {}", archive_path.display()))?;
221 let fallback_path = destination.join(file_name);
222 std::fs::copy(archive_path, &fallback_path).with_context(|| {
223 format!(
224 "failed to copy archive {} to {}",
225 archive_path.display(),
226 fallback_path.display()
227 )
228 })?;
229 Ok(())
230}
231
232fn extract_zip(archive_path: &Path, destination: &Path) -> Result<()> {
233 let file = File::open(archive_path)
234 .with_context(|| format!("failed to open archive {}", archive_path.display()))?;
235 let mut archive = ZipArchive::new(file)
236 .with_context(|| format!("failed to read ZIP archive {}", archive_path.display()))?;
237
238 for index in 0..archive.len() {
239 let mut entry = archive
240 .by_index(index)
241 .with_context(|| format!("failed to read ZIP entry {index}"))?;
242 let enclosed = entry
243 .enclosed_name()
244 .ok_or_else(|| anyhow!("archive contains unsafe path: {}", entry.name()))?;
245 let output_path = destination.join(enclosed);
246
247 if entry.name().ends_with('/') {
248 std::fs::create_dir_all(&output_path)
249 .with_context(|| format!("failed to create {}", output_path.display()))?;
250 continue;
251 }
252
253 if let Some(parent) = output_path.parent() {
254 std::fs::create_dir_all(parent)
255 .with_context(|| format!("failed to create {}", parent.display()))?;
256 }
257
258 let mut output = File::create(&output_path)
259 .with_context(|| format!("failed to create {}", output_path.display()))?;
260 io::copy(&mut entry, &mut output)
261 .with_context(|| format!("failed to extract {}", output_path.display()))?;
262 }
263
264 Ok(())
265}
266
267pub(crate) fn resolve_cmd_path(extracted_dir: &Path, cmd: &str) -> PathBuf {
268 let sanitized = cmd.trim_start_matches("./");
269 let candidate = PathBuf::from(sanitized);
270 if candidate.is_absolute() {
271 return candidate;
272 }
273
274 let mut resolved = extracted_dir.to_path_buf();
275 for component in candidate.components() {
276 match component {
277 Component::CurDir => {}
278 other => resolved.push(other.as_os_str()),
279 }
280 }
281 resolved
282}
283
284fn user_install_dir() -> Result<PathBuf> {
285 #[cfg(windows)]
286 {
287 let home = dirs::home_dir()
288 .ok_or_else(|| anyhow!("could not determine the current user's home directory"))?;
289 Ok(home.join(".acp-agent").join("bin"))
290 }
291
292 #[cfg(not(windows))]
293 {
294 let home = dirs::home_dir()
295 .ok_or_else(|| anyhow!("could not determine the current user's home directory"))?;
296 Ok(home.join(".local").join("bin"))
297 }
298}
299
300pub(crate) async fn make_executable(path: &Path) -> Result<(), io::Error> {
301 #[cfg(unix)]
302 {
303 use std::os::unix::fs::PermissionsExt;
304
305 let mut permissions = fs::metadata(path).await?.permissions();
306 permissions.set_mode(0o755);
307 fs::set_permissions(path, permissions).await?;
308 }
309
310 #[cfg(not(unix))]
311 {
312 let _ = path;
313 }
314
315 Ok(())
316}
317
318async fn run_command<I, S>(program: &str, args: I, subject: &str) -> Result<()>
319where
320 I: IntoIterator<Item = S>,
321 S: Into<OsString>,
322{
323 let args_vec: Vec<OsString> = args.into_iter().map(Into::into).collect();
324 let output = Command::new(program)
325 .args(&args_vec)
326 .output()
327 .await
328 .with_context(|| format!("failed to run {program}"))?;
329
330 if output.status.success() {
331 return Ok(());
332 }
333
334 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
335 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
336 let detail = if !stderr.is_empty() { stderr } else { stdout };
337
338 if detail.is_empty() {
339 bail!("failed to run {program} for {subject}");
340 }
341
342 bail!("failed to run {program} for {subject}: {detail}");
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum InstallMethod {
348 Binary,
350 Npx,
352 Uvx,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum InstallOutcome {
359 Binary {
361 agent_id: String,
363 path: PathBuf,
365 },
366 PackageManager {
368 agent_id: String,
370 method: InstallMethod,
372 package: String,
374 },
375}
376
377impl std::fmt::Display for InstallOutcome {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 match self {
380 Self::Binary { agent_id, path } => {
381 write!(f, "Installed {agent_id} to {}", path.display())
382 }
383 Self::PackageManager {
384 agent_id,
385 method,
386 package,
387 } => {
388 let installer = match method {
389 InstallMethod::Binary => "binary",
390 InstallMethod::Npx => "npm",
391 InstallMethod::Uvx => "uv",
392 };
393 write!(f, "Installed {agent_id} via {installer}: {package}")
394 }
395 }
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::registry::AgentDistribution;
403
404 fn sample_agent() -> RegistryAgent {
405 RegistryAgent {
406 id: "demo".to_string(),
407 name: "Demo".to_string(),
408 version: "1.0.0".to_string(),
409 description: "Demo agent".to_string(),
410 repository: None,
411 website: None,
412 authors: vec!["ACP".to_string()],
413 license: "MIT".to_string(),
414 icon: None,
415 distribution: AgentDistribution {
416 binary: None,
417 npx: None,
418 uvx: None,
419 },
420 }
421 }
422
423 #[test]
424 fn resolves_relative_cmd_paths() {
425 let base = Path::new("/tmp/acp-agent");
426 let resolved = resolve_cmd_path(base, "./dist-package/cursor-agent");
427 assert_eq!(resolved, base.join("dist-package").join("cursor-agent"));
428 }
429
430 #[tokio::test]
431 async fn reports_missing_distribution() {
432 let agent = sample_agent();
433
434 let error = install_from_registry(
435 &Registry {
436 version: "1".to_string(),
437 agents: vec![],
438 extensions: None,
439 },
440 &agent,
441 )
442 .await
443 .expect_err("install should fail");
444
445 assert_eq!(
446 error.to_string(),
447 "agent \"demo\" does not have an installable distribution"
448 );
449 }
450}