1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum InstallTarget {
19 Daemon,
20 Mcp,
21}
22
23impl InstallTarget {
24 fn repo(&self) -> &'static str {
26 match self {
27 InstallTarget::Daemon => "acp-daemon",
28 InstallTarget::Mcp => "acp-mcp",
29 }
30 }
31
32 fn binary_name(&self) -> &'static str {
34 match self {
35 InstallTarget::Daemon => "acpd",
36 InstallTarget::Mcp => "acp-mcp",
37 }
38 }
39
40 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
61pub struct InstallOptions {
63 pub targets: Vec<InstallTarget>,
64 pub force: bool,
65 pub version: Option<String>,
66}
67
68fn 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
83fn 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#[derive(Debug, serde::Deserialize)]
92struct GitHubRelease {
93 tag_name: String,
94 assets: Vec<GitHubAsset>,
95}
96
97#[derive(Debug, serde::Deserialize)]
99struct GitHubAsset {
100 name: String,
101 browser_download_url: String,
102}
103
104fn 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
123fn 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
148fn find_asset_for_platform<'a>(
150 release: &'a GitHubRelease,
151 platform: &str,
152 binary_name: &str,
153) -> Option<&'a GitHubAsset> {
154 let ext = if platform.contains("windows") {
156 ".zip"
157 } else {
158 ".tar.gz"
159 };
160
161 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 let alt_name = format!("{}{}", platform, ext);
171 release.assets.iter().find(|a| a.name.contains(&alt_name))
172 })
173}
174
175fn 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 fs::create_dir_all(install_dir).context("Failed to create install directory")?;
186
187 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 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 #[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
222fn 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 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
252fn 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 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
279fn 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
295fn suggest_path_update(install_dir: &Path) {
297 let path_str = install_dir.display().to_string();
298
299 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
319pub 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 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 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 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 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
406pub 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
438pub 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}