acp/commands/
install.rs

1//! @acp:module "Install Command"
2//! @acp:summary "Plugin installation for ACP daemon and MCP server"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! Installs ACP plugins (daemon, mcp) by downloading pre-built binaries
7//! from GitHub releases.
8
9use std::fs::{self, File};
10use std::io::{self, Read};
11use std::path::{Path, PathBuf};
12
13use anyhow::{anyhow, Context, Result};
14use console::style;
15
16/// Plugin installation targets
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum InstallTarget {
19    Daemon,
20    Mcp,
21}
22
23impl InstallTarget {
24    /// GitHub repository name
25    fn repo(&self) -> &'static str {
26        match self {
27            InstallTarget::Daemon => "acp-daemon",
28            InstallTarget::Mcp => "acp-mcp",
29        }
30    }
31
32    /// Binary name
33    fn binary_name(&self) -> &'static str {
34        match self {
35            InstallTarget::Daemon => "acpd",
36            InstallTarget::Mcp => "acp-mcp",
37        }
38    }
39
40    /// Display name
41    fn display_name(&self) -> &'static str {
42        match self {
43            InstallTarget::Daemon => "ACP Daemon",
44            InstallTarget::Mcp => "ACP MCP Server",
45        }
46    }
47}
48
49impl std::str::FromStr for InstallTarget {
50    type Err = String;
51
52    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
53        match s.to_lowercase().as_str() {
54            "daemon" | "acpd" => Ok(InstallTarget::Daemon),
55            "mcp" | "acp-mcp" => Ok(InstallTarget::Mcp),
56            _ => Err(format!("Unknown target: {}. Use 'daemon' or 'mcp'", s)),
57        }
58    }
59}
60
61/// Installation options
62pub struct InstallOptions {
63    pub targets: Vec<InstallTarget>,
64    pub force: bool,
65    pub version: Option<String>,
66}
67
68/// Detect current platform
69fn detect_platform() -> Result<&'static str> {
70    let os = std::env::consts::OS;
71    let arch = std::env::consts::ARCH;
72
73    match (os, arch) {
74        ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
75        ("macos", "x86_64") => Ok("x86_64-apple-darwin"),
76        ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
77        ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
78        ("windows", "x86_64") => Ok("x86_64-pc-windows-msvc"),
79        _ => Err(anyhow!("Unsupported platform: {}-{}", os, arch)),
80    }
81}
82
83/// Get installation directory
84fn get_install_dir() -> PathBuf {
85    dirs::home_dir()
86        .map(|h| h.join(".acp").join("bin"))
87        .unwrap_or_else(|| PathBuf::from(".acp/bin"))
88}
89
90/// GitHub API response for release
91#[derive(Debug, serde::Deserialize)]
92struct GitHubRelease {
93    tag_name: String,
94    assets: Vec<GitHubAsset>,
95}
96
97/// GitHub API response for asset
98#[derive(Debug, serde::Deserialize)]
99struct GitHubAsset {
100    name: String,
101    browser_download_url: String,
102}
103
104/// Fetch latest release info from GitHub
105fn fetch_latest_release(repo: &str) -> Result<GitHubRelease> {
106    let url = format!(
107        "https://api.github.com/repos/acp-protocol/{}/releases/latest",
108        repo
109    );
110
111    let response = ureq::get(&url)
112        .set("User-Agent", "acp-cli")
113        .call()
114        .context("Failed to fetch release info")?;
115
116    let release: GitHubRelease = response
117        .into_json()
118        .context("Failed to parse release info")?;
119
120    Ok(release)
121}
122
123/// Fetch specific release info from GitHub
124fn fetch_release(repo: &str, version: &str) -> Result<GitHubRelease> {
125    let tag = if version.starts_with('v') {
126        version.to_string()
127    } else {
128        format!("v{}", version)
129    };
130
131    let url = format!(
132        "https://api.github.com/repos/acp-protocol/{}/releases/tags/{}",
133        repo, tag
134    );
135
136    let response = ureq::get(&url)
137        .set("User-Agent", "acp-cli")
138        .call()
139        .context("Failed to fetch release info")?;
140
141    let release: GitHubRelease = response
142        .into_json()
143        .context("Failed to parse release info")?;
144
145    Ok(release)
146}
147
148/// Find asset for the current platform
149fn find_asset_for_platform<'a>(
150    release: &'a GitHubRelease,
151    platform: &str,
152    binary_name: &str,
153) -> Option<&'a GitHubAsset> {
154    // Look for tar.gz or zip based on platform
155    let ext = if platform.contains("windows") {
156        ".zip"
157    } else {
158        ".tar.gz"
159    };
160
161    // Asset name format: {binary}-{platform}.{ext}
162    let expected_name = format!("{}-{}{}", binary_name, platform, ext);
163
164    release
165        .assets
166        .iter()
167        .find(|a| a.name == expected_name)
168        .or_else(|| {
169            // Also try without binary name prefix (just platform)
170            let alt_name = format!("{}{}", platform, ext);
171            release.assets.iter().find(|a| a.name.contains(&alt_name))
172        })
173}
174
175/// Download and extract binary
176fn download_and_extract(
177    url: &str,
178    install_dir: &PathBuf,
179    binary_name: &str,
180    is_windows: bool,
181) -> Result<PathBuf> {
182    println!("  {} Downloading...", style("↓").blue());
183
184    // Create install directory
185    fs::create_dir_all(install_dir).context("Failed to create install directory")?;
186
187    // Download to temp file
188    let response = ureq::get(url)
189        .set("User-Agent", "acp-cli")
190        .call()
191        .context("Failed to download")?;
192
193    let mut bytes = Vec::new();
194    response
195        .into_reader()
196        .read_to_end(&mut bytes)
197        .context("Failed to read download")?;
198
199    println!("  {} Extracting...", style("⚙").blue());
200
201    // Extract binary
202    let binary_path = if is_windows {
203        extract_zip(&bytes, install_dir, binary_name)?
204    } else {
205        extract_tar_gz(&bytes, install_dir, binary_name)?
206    };
207
208    // Make executable on Unix
209    #[cfg(unix)]
210    {
211        use std::os::unix::fs::PermissionsExt;
212        let mut perms = fs::metadata(&binary_path)
213            .context("Failed to get permissions")?
214            .permissions();
215        perms.set_mode(0o755);
216        fs::set_permissions(&binary_path, perms).context("Failed to set permissions")?;
217    }
218
219    Ok(binary_path)
220}
221
222/// Extract tar.gz archive
223fn extract_tar_gz(data: &[u8], install_dir: &Path, binary_name: &str) -> Result<PathBuf> {
224    use flate2::read::GzDecoder;
225    use tar::Archive;
226
227    let decoder = GzDecoder::new(data);
228    let mut archive = Archive::new(decoder);
229
230    let binary_path = install_dir.join(binary_name);
231
232    for entry in archive.entries().context("Failed to read archive")? {
233        let mut entry = entry.context("Failed to read entry")?;
234        let entry_path = entry.path().context("Failed to get path")?;
235
236        // Check if this is the binary we want
237        let file_name = entry_path
238            .file_name()
239            .and_then(|n| n.to_str())
240            .unwrap_or("");
241
242        if file_name == binary_name || file_name == format!("{}.exe", binary_name) {
243            let mut file = File::create(&binary_path).context("Failed to create file")?;
244            io::copy(&mut entry, &mut file).context("Failed to extract")?;
245            return Ok(binary_path);
246        }
247    }
248
249    Err(anyhow!("Binary '{}' not found in archive", binary_name))
250}
251
252/// Extract zip archive
253fn extract_zip(data: &[u8], install_dir: &Path, binary_name: &str) -> Result<PathBuf> {
254    use std::io::Cursor;
255    use zip::ZipArchive;
256
257    let cursor = Cursor::new(data);
258    let mut archive = ZipArchive::new(cursor).context("Failed to read zip")?;
259
260    let binary_path = install_dir.join(format!("{}.exe", binary_name));
261
262    for i in 0..archive.len() {
263        let mut file = archive.by_index(i).context("Failed to read entry")?;
264
265        let file_name = file.name();
266
267        // Check if this is the binary we want
268        if file_name.ends_with(&format!("{}.exe", binary_name)) || file_name.ends_with(binary_name)
269        {
270            let mut outfile = File::create(&binary_path).context("Failed to create file")?;
271            io::copy(&mut file, &mut outfile).context("Failed to extract")?;
272            return Ok(binary_path);
273        }
274    }
275
276    Err(anyhow!("Binary '{}' not found in zip", binary_name))
277}
278
279/// Check if binary already exists
280fn check_existing(install_dir: &Path, binary_name: &str, is_windows: bool) -> Option<PathBuf> {
281    let name = if is_windows {
282        format!("{}.exe", binary_name)
283    } else {
284        binary_name.to_string()
285    };
286
287    let path = install_dir.join(name);
288    if path.exists() {
289        Some(path)
290    } else {
291        None
292    }
293}
294
295/// Suggest PATH update
296fn suggest_path_update(install_dir: &Path) {
297    let path_str = install_dir.display().to_string();
298
299    // Check if already in PATH
300    if let Ok(path) = std::env::var("PATH") {
301        if path.contains(&path_str) {
302            return;
303        }
304    }
305
306    println!();
307    println!(
308        "{} Add the following to your shell profile:",
309        style("Note:").yellow()
310    );
311
312    if cfg!(windows) {
313        println!("  setx PATH \"%PATH%;{}\"", install_dir.display());
314    } else {
315        println!("  export PATH=\"$PATH:{}\"", install_dir.display());
316    }
317}
318
319/// Execute the install command
320pub fn execute_install(options: InstallOptions) -> Result<()> {
321    let platform = detect_platform()?;
322    let install_dir = get_install_dir();
323    let is_windows = platform.contains("windows");
324
325    println!(
326        "{} Installing ACP plugins to {}",
327        style("→").blue(),
328        style(install_dir.display()).cyan()
329    );
330    println!("  Platform: {}", style(platform).dim());
331    println!();
332
333    let mut installed = Vec::new();
334
335    for target in &options.targets {
336        println!(
337            "{} {}",
338            style("Installing").green().bold(),
339            style(target.display_name()).cyan()
340        );
341
342        // Check if already installed
343        if let Some(existing) = check_existing(&install_dir, target.binary_name(), is_windows) {
344            if !options.force {
345                println!(
346                    "  {} Already installed at {}",
347                    style("✓").green(),
348                    existing.display()
349                );
350                println!("  Use --force to reinstall");
351                continue;
352            }
353            println!("  {} Reinstalling...", style("!").yellow());
354        }
355
356        // Fetch release info
357        let release = if let Some(ref version) = options.version {
358            fetch_release(target.repo(), version)?
359        } else {
360            fetch_latest_release(target.repo())?
361        };
362
363        println!("  Version: {}", style(&release.tag_name).dim());
364
365        // Find asset for platform
366        let asset =
367            find_asset_for_platform(&release, platform, target.binary_name()).ok_or_else(|| {
368                anyhow!(
369                    "No binary found for {} on {}. Available: {:?}",
370                    target.display_name(),
371                    platform,
372                    release.assets.iter().map(|a| &a.name).collect::<Vec<_>>()
373                )
374            })?;
375
376        // Download and extract
377        let binary_path = download_and_extract(
378            &asset.browser_download_url,
379            &install_dir,
380            target.binary_name(),
381            is_windows,
382        )?;
383
384        println!(
385            "  {} Installed to {}",
386            style("✓").green(),
387            binary_path.display()
388        );
389
390        installed.push(target.display_name());
391        println!();
392    }
393
394    if !installed.is_empty() {
395        println!(
396            "{} Successfully installed: {}",
397            style("✓").green().bold(),
398            installed.join(", ")
399        );
400        suggest_path_update(&install_dir);
401    }
402
403    Ok(())
404}
405
406/// List installed plugins
407pub fn execute_list_installed() -> Result<()> {
408    let install_dir = get_install_dir();
409
410    println!(
411        "{} Installed plugins in {}",
412        style("→").blue(),
413        style(install_dir.display()).cyan()
414    );
415
416    let is_windows = cfg!(windows);
417
418    for target in [InstallTarget::Daemon, InstallTarget::Mcp] {
419        if let Some(path) = check_existing(&install_dir, target.binary_name(), is_windows) {
420            println!(
421                "  {} {} ({})",
422                style("✓").green(),
423                target.display_name(),
424                path.display()
425            );
426        } else {
427            println!(
428                "  {} {} (not installed)",
429                style("✗").dim(),
430                target.display_name()
431            );
432        }
433    }
434
435    Ok(())
436}
437
438/// Uninstall a plugin
439pub fn execute_uninstall(targets: Vec<InstallTarget>) -> Result<()> {
440    let install_dir = get_install_dir();
441    let is_windows = cfg!(windows);
442
443    for target in targets {
444        let binary_name = if is_windows {
445            format!("{}.exe", target.binary_name())
446        } else {
447            target.binary_name().to_string()
448        };
449
450        let path = install_dir.join(&binary_name);
451
452        if path.exists() {
453            fs::remove_file(&path)?;
454            println!(
455                "{} Uninstalled {}",
456                style("✓").green(),
457                target.display_name()
458            );
459        } else {
460            println!(
461                "{} {} is not installed",
462                style("!").yellow(),
463                target.display_name()
464            );
465        }
466    }
467
468    Ok(())
469}