Skip to main content

acp_agent/commands/
install.rs

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
19/// Installs an agent by ID using the configured registry distribution.
20///
21/// The function mirrors the CLI `install` subcommand and returns a
22/// descriptive `InstallOutcome` so callers can log where the agent ended up.
23pub 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(&registry, agent).await
28}
29
30/// Core installer that inspects each distribution in priority order.
31///
32/// Binary archives are installed to the user-local bin directory when a
33/// platform-matching release exists; otherwise the function falls back to npm or
34/// uv package installers depending on what the registry exposes.
35pub 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/// Identifier for how an agent was installed when the CLI reports success.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum InstallMethod {
348    /// The registry provided a ready-to-run binary archive.
349    Binary,
350    /// The registry points to an npm package invoking `npx`.
351    Npx,
352    /// The registry points to a uvx package invoking `uv`.
353    Uvx,
354}
355
356/// Outcome data that is printed by the `install` subcommand.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum InstallOutcome {
359    /// A binary archive was downloaded and copied under the user bin directory.
360    Binary {
361        /// ID of the agent that was installed.
362        agent_id: String,
363        /// Filesystem path of the copied executable.
364        path: PathBuf,
365    },
366    /// A package manager (npm or uv) installed a wrapper on behalf of the agent.
367    PackageManager {
368        /// ID of the agent that was installed.
369        agent_id: String,
370        /// Which package-manager strategy was used.
371        method: InstallMethod,
372        /// Package identifier handed to the installer.
373        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}