1use anyhow::{anyhow, bail, Context, Result};
2use std::path::{Component, Path, PathBuf};
3use std::process::Command;
4
5use super::manifest::{ExternalSource, GitPin, PluginEntry, PluginSource};
6use super::marketplace::sanitize_name;
7use super::paths;
8use super::state::{
9 load_installed_plugins_file, load_marketplaces_file, plugin_id, save_installed_plugins_file,
10 InstalledPluginEntry,
11};
12use super::url::validate_git_url;
13
14#[derive(Debug, Clone)]
15pub struct InstalledPluginInfo {
16 pub plugin: String,
17 pub marketplace: String,
18 pub plugin_dir: String,
19}
20
21fn resolve_inline_dir(source: &str, mp_root_rel: &str) -> Result<String> {
25 validate_plugin_source(source)?;
26 let normalized = source.trim_start_matches("./");
27 if normalized.is_empty() {
28 Ok(mp_root_rel.to_string())
29 } else {
30 Ok(format!("{}/{}", mp_root_rel, normalized.trim_end_matches('/')))
31 }
32}
33
34fn install_external(
38 plugin_key: &str,
39 marketplace: &str,
40 ext: &ExternalSource,
41) -> Result<String> {
42 let plugins_root = paths::plugins_root().ok_or_else(|| anyhow!("no plugin home"))?;
43 let target_rel = format!("installed/{}/{}", marketplace, plugin_key);
44 let target_abs = plugins_root.join(&target_rel);
45 if target_abs.exists() {
46 bail!(
47 "plugin install dir already exists: {}",
48 target_abs.display()
49 );
50 }
51 if let Some(parent) = target_abs.parent() {
52 std::fs::create_dir_all(parent).ok();
53 }
54
55 match ext {
56 ExternalSource::Url { url, pin } | ExternalSource::Git { url, pin } => {
57 validate_git_url(url)?;
58 git_clone_with_pin(url, &target_abs, pin)
59 .with_context(|| format!("clone {}", url))?;
60 }
61 ExternalSource::Github { repo, pin } => {
62 let url = expand_github_repo(repo)?;
63 git_clone_with_pin(&url, &target_abs, pin)
64 .with_context(|| format!("clone {}", url))?;
65 }
66 ExternalSource::Local { path } => {
67 let src = expand_local_path(path)?;
68 copy_dir_recursive(&src, &target_abs)
69 .with_context(|| format!("copy {}", src.display()))?;
70 }
71 }
72 Ok(target_rel)
73}
74
75fn expand_github_repo(repo: &str) -> Result<String> {
79 let trimmed = repo.trim().trim_end_matches(".git").trim_matches('/');
80 let parts: Vec<&str> = trimmed.split('/').collect();
81 if parts.len() != 2 || parts.iter().any(|s| s.is_empty()) {
82 bail!("github repo must be in `owner/name` form, got `{}`", repo);
83 }
84 for seg in &parts {
85 if !seg
86 .chars()
87 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
88 || seg.contains("..")
89 {
90 bail!("github repo `{}` contains disallowed characters", repo);
91 }
92 if seg.starts_with('-') {
96 bail!("github repo `{}` segment must not start with '-'", repo);
97 }
98 }
99 Ok(format!("https://github.com/{}/{}.git", parts[0], parts[1]))
100}
101
102fn expand_local_path(path: &str) -> Result<PathBuf> {
105 let expanded = if let Some(rest) = path.strip_prefix("~/") {
106 crate::tool::real_home_dir()
107 .ok_or_else(|| anyhow!("no home dir to expand `~`"))?
108 .join(rest)
109 } else if path == "~" {
110 crate::tool::real_home_dir().ok_or_else(|| anyhow!("no home dir to expand `~`"))?
111 } else {
112 PathBuf::from(path)
113 };
114 if !expanded.exists() {
115 bail!("local plugin source does not exist: {}", expanded.display());
116 }
117 Ok(expanded)
118}
119
120fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
121 std::fs::create_dir_all(dst)?;
122 for entry in std::fs::read_dir(src)? {
123 let entry = entry?;
124 let ty = entry.file_type()?;
125 let from = entry.path();
126 let to = dst.join(entry.file_name());
127 if ty.is_dir() {
128 copy_dir_recursive(&from, &to)?;
129 } else if ty.is_symlink() {
130 let resolved = std::fs::read_link(&from)?;
133 let abs = if resolved.is_absolute() {
134 resolved
135 } else {
136 from.parent().unwrap_or(Path::new(".")).join(resolved)
137 };
138 if abs.is_dir() {
139 copy_dir_recursive(&abs, &to)?;
140 } else {
141 std::fs::copy(&abs, &to)?;
142 }
143 } else {
144 std::fs::copy(&from, &to)?;
145 }
146 }
147 Ok(())
148}
149
150fn git_clone_with_pin(url: &str, target: &Path, pin: &GitPin) -> Result<()> {
151 let mut cmd = Command::new("git");
152 cmd.arg("clone");
153 let needs_full_history = pin.commit.is_some() || pin.tag.is_some() || pin.git_ref.is_some();
154 if !needs_full_history {
155 cmd.args(["--depth", "1"]);
156 }
157 if let Some(branch) = &pin.branch {
158 cmd.args(["--branch", branch]);
159 }
160 cmd.arg(url).arg(target);
161 let out = cmd.output().context("spawn git clone")?;
162 if !out.status.success() {
163 bail!("git clone failed: {}", String::from_utf8_lossy(&out.stderr));
164 }
165
166 let pin_ref = pin
168 .commit
169 .as_deref()
170 .or(pin.tag.as_deref())
171 .or(pin.git_ref.as_deref());
172 if let Some(rev) = pin_ref {
173 let out = Command::new("git")
174 .args(["checkout", "--detach", rev])
175 .current_dir(target)
176 .output()
177 .context("spawn git checkout")?;
178 if !out.status.success() {
179 bail!(
180 "git checkout {} failed: {}",
181 rev,
182 String::from_utf8_lossy(&out.stderr)
183 );
184 }
185 }
186 Ok(())
187}
188
189fn normalize_git_url(u: &str) -> String {
193 u.trim().trim_end_matches('/').trim_end_matches(".git").to_string()
194}
195
196fn external_matches_marketplace(ext: &ExternalSource, mp_url: &str) -> bool {
201 let (url, pin) = match ext {
202 ExternalSource::Url { url, pin } | ExternalSource::Git { url, pin } => {
203 (url.clone(), pin)
204 }
205 ExternalSource::Github { repo, pin } => match expand_github_repo(repo) {
206 Ok(u) => (u, pin),
207 Err(_) => return false,
208 },
209 ExternalSource::Local { .. } => return false,
210 };
211 if pin.branch.is_some()
212 || pin.tag.is_some()
213 || pin.commit.is_some()
214 || pin.git_ref.is_some()
215 {
216 return false;
217 }
218 normalize_git_url(&url) == normalize_git_url(mp_url)
219}
220
221fn validate_plugin_source(source: &str) -> Result<()> {
225 if source.is_empty() {
226 return Ok(());
227 }
228 let p = Path::new(source);
229 for comp in p.components() {
230 match comp {
231 Component::Normal(s) => {
232 let s = s.to_string_lossy();
233 if s.is_empty() || s == ".." || s.contains('\0') {
234 bail!("plugin source path '{}' contains disallowed components", source);
235 }
236 }
237 Component::CurDir => {
238 }
240 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
241 bail!("plugin source path '{}' contains disallowed components", source);
242 }
243 }
244 }
245 Ok(())
246}
247
248pub fn install(plugin: &str, marketplace: &str) -> Result<InstalledPluginInfo> {
249 let mp_state = load_marketplaces_file(&paths::marketplaces_file().unwrap())?;
250 let entry = mp_state
251 .marketplaces
252 .get(marketplace)
253 .ok_or_else(|| anyhow!("marketplace `{}` not registered", marketplace))?;
254 if !entry.plugins.iter().any(|p| p == plugin) {
255 bail!("plugin `{}` not found in marketplace `{}`", plugin, marketplace);
256 }
257
258 let mp_root_rel = format!("marketplaces/{}", marketplace);
260 let mp_root_abs = paths::plugins_root().unwrap().join(&mp_root_rel);
261 let manifest = super::manifest::load_marketplace_manifest(&mp_root_abs)?;
262 let plugin_entry: PluginEntry = match manifest {
263 Some(m) => m
264 .plugins
265 .into_iter()
266 .find(|p| sanitize_name(&p.name) == plugin || p.name == plugin)
267 .ok_or_else(|| anyhow!("plugin `{}` missing from manifest", plugin))?,
268 None => PluginEntry {
269 name: plugin.to_string(),
270 source: PluginSource::Inline("./".into()),
271 description: None,
272 },
273 };
274
275 let plugin_key = sanitize_name(plugin);
278 if plugin_key.is_empty() {
279 bail!("plugin name `{}` sanitized to empty string", plugin);
280 }
281
282 let plugin_dir_rel = match &plugin_entry.source {
283 PluginSource::Inline(s) => resolve_inline_dir(s, &mp_root_rel)?,
284 PluginSource::External(ext) => {
285 if external_matches_marketplace(ext, &entry.source) {
289 mp_root_rel.clone()
290 } else {
291 install_external(&plugin_key, marketplace, ext)?
292 }
293 }
294 };
295
296 let id = plugin_id(&plugin_key, marketplace);
297 let installed_path = paths::installed_plugins_file().unwrap();
298 let mut installed = load_installed_plugins_file(&installed_path)?;
299 if installed.plugins.contains_key(&id) {
300 if plugin_dir_rel.starts_with("installed/") {
302 let abs = paths::plugins_root().unwrap().join(&plugin_dir_rel);
303 std::fs::remove_dir_all(&abs).ok();
304 }
305 bail!("plugin `{}` already installed; uninstall first", id);
306 }
307 installed.plugins.insert(
308 id.clone(),
309 InstalledPluginEntry {
310 marketplace: marketplace.to_string(),
311 plugin: plugin_key.clone(),
312 plugin_dir: plugin_dir_rel.clone(),
313 installed_at: chrono::Utc::now().to_rfc3339(),
314 },
315 );
316 save_installed_plugins_file(&installed_path, &installed)?;
317
318 Ok(InstalledPluginInfo {
319 plugin: plugin_key,
320 marketplace: marketplace.to_string(),
321 plugin_dir: plugin_dir_rel,
322 })
323}
324
325pub fn uninstall(plugin: &str, marketplace: &str) -> Result<()> {
326 let plugin_key = sanitize_name(plugin);
327 let id = plugin_id(&plugin_key, marketplace);
328 let installed_path = paths::installed_plugins_file().unwrap();
329 let mut installed = load_installed_plugins_file(&installed_path)?;
330 let entry = installed
331 .plugins
332 .remove(&id)
333 .ok_or_else(|| anyhow!("plugin `{}` not installed", id))?;
334 save_installed_plugins_file(&installed_path, &installed)?;
335
336 if entry.plugin_dir.starts_with("installed/") {
339 if let Some(root) = paths::plugins_root() {
340 let abs = root.join(&entry.plugin_dir);
341 if abs.exists() {
342 std::fs::remove_dir_all(&abs).ok();
343 }
344 }
345 }
346 Ok(())
347}
348
349pub fn list_installed() -> Result<Vec<InstalledPluginInfo>> {
350 let installed = load_installed_plugins_file(&paths::installed_plugins_file().unwrap())?;
351 Ok(installed
352 .plugins
353 .into_values()
354 .map(|e| InstalledPluginInfo {
355 plugin: e.plugin,
356 marketplace: e.marketplace,
357 plugin_dir: e.plugin_dir,
358 })
359 .collect())
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::plugin::marketplace::add_marketplace;
366 use crate::plugin::test_support::isolated_home;
367 use std::path::PathBuf;
368 use std::process::Command;
369
370 fn make_repo(name: &str, manifest: Option<&str>) -> PathBuf {
371 let work = tempfile::tempdir().unwrap().keep();
372 let repo = work.join(name);
373 std::fs::create_dir_all(&repo).unwrap();
374 Command::new("git").args(["init", "-q"]).current_dir(&repo).status().unwrap();
375 Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&repo).status().unwrap();
376 Command::new("git").args(["config", "user.name", "t"]).current_dir(&repo).status().unwrap();
377 if let Some(m) = manifest {
378 std::fs::create_dir_all(repo.join(".atomcode-plugin")).unwrap();
379 std::fs::write(repo.join(".atomcode-plugin/marketplace.json"), m).unwrap();
380 }
381 std::fs::write(repo.join("README"), "x").unwrap();
382 Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
383 Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&repo).status().unwrap();
384 repo
385 }
386
387 #[test]
388 #[serial_test::serial]
389 fn install_single_plugin_fallback() {
390 let _home = isolated_home();
391 let repo = make_repo("solo", None);
392 add_marketplace(&format!("file://{}", repo.display())).unwrap();
393 let info = install("solo", "solo").unwrap();
394 assert_eq!(info.plugin_dir, "marketplaces/solo");
395 }
396
397 #[test]
398 #[serial_test::serial]
399 fn install_rejects_duplicate() {
400 let _home = isolated_home();
401 let repo = make_repo("dup", None);
402 add_marketplace(&format!("file://{}", repo.display())).unwrap();
403 install("dup", "dup").unwrap();
404 assert!(install("dup", "dup").is_err());
405 }
406
407 #[test]
408 #[serial_test::serial]
409 fn uninstall_works() {
410 let _home = isolated_home();
411 let repo = make_repo("u", None);
412 add_marketplace(&format!("file://{}", repo.display())).unwrap();
413 install("u", "u").unwrap();
414 uninstall("u", "u").unwrap();
415 assert!(list_installed().unwrap().is_empty());
416 }
417
418 #[test]
419 #[serial_test::serial]
420 fn install_with_subdir_source() {
421 let _home = isolated_home();
422 let manifest = r#"{"name":"mp","plugins":[{"name":"sub","source":"plugins/sub"}]}"#;
423 let repo = make_repo("mp", Some(manifest));
424 std::fs::create_dir_all(repo.join("plugins/sub")).unwrap();
426 std::fs::write(repo.join("plugins/sub/plugin.json"), "{}").unwrap();
427 Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
428 Command::new("git").args(["commit", "-q", "-m", "add sub"]).current_dir(&repo).status().unwrap();
429 add_marketplace(&format!("file://{}", repo.display())).unwrap();
430 let info = install("sub", "mp").unwrap();
431 assert_eq!(info.plugin_dir, "marketplaces/mp/plugins/sub");
432 }
433
434 #[test]
438 #[serial_test::serial]
439 fn install_rejects_traversal_in_plugin_source() {
440 let _home = isolated_home();
441 let manifest = r#"{"name":"mp2","plugins":[{"name":"esc","source":"../../etc"}]}"#;
442 let repo = make_repo("mp2", Some(manifest));
443 add_marketplace(&format!("file://{}", repo.display())).unwrap();
444 let err = install("esc", "mp2").unwrap_err();
445 assert!(
446 err.to_string().contains("disallowed components"),
447 "expected traversal rejection, got: {}",
448 err
449 );
450 }
451
452 #[test]
456 #[serial_test::serial]
457 fn install_external_url_clones_separate_repo() {
458 let _home = isolated_home();
459 let plugin_repo = make_repo("upstream", None);
461 std::fs::write(plugin_repo.join("PLUGIN_MARKER"), "yes").unwrap();
463 Command::new("git").args(["add", "-A"]).current_dir(&plugin_repo).status().unwrap();
464 Command::new("git").args(["commit", "-q", "-m", "marker"]).current_dir(&plugin_repo).status().unwrap();
465
466 let plugin_url = format!("file://{}", plugin_repo.display());
468 let manifest = format!(
469 r#"{{"name":"mp_ext","plugins":[{{"name":"ext","source":{{"source":"url","url":"{}"}}}}]}}"#,
470 plugin_url
471 );
472 let mp_repo = make_repo("mp_ext", Some(&manifest));
473 add_marketplace(&format!("file://{}", mp_repo.display())).unwrap();
474
475 let info = install("ext", "mp_ext").unwrap();
476 assert_eq!(info.plugin_dir, "installed/mp_ext/ext");
477
478 let abs = paths::plugins_root().unwrap().join(&info.plugin_dir);
479 assert!(abs.join("PLUGIN_MARKER").exists(), "external clone missing");
480
481 uninstall("ext", "mp_ext").unwrap();
483 assert!(!abs.exists(), "uninstall should remove installed/* clone");
484 }
485
486 #[test]
488 #[serial_test::serial]
489 fn install_external_local_copies_tree() {
490 let _home = isolated_home();
491 let local_src = tempfile::tempdir().unwrap().keep();
492 std::fs::create_dir_all(local_src.join("skills/x")).unwrap();
493 std::fs::write(local_src.join("skills/x/SKILL.md"), "body").unwrap();
494
495 let manifest = format!(
496 r#"{{"name":"mp_local","plugins":[{{"name":"loc","source":{{"source":"local","path":"{}"}}}}]}}"#,
497 local_src.display()
498 );
499 let mp_repo = make_repo("mp_local", Some(&manifest));
500 add_marketplace(&format!("file://{}", mp_repo.display())).unwrap();
501 let info = install("loc", "mp_local").unwrap();
502
503 let abs = paths::plugins_root().unwrap().join(&info.plugin_dir);
504 assert!(abs.join("skills/x/SKILL.md").exists(), "local copy missing");
505 }
506
507 #[test]
511 #[serial_test::serial]
512 fn install_external_url_dedups_with_marketplace() {
513 let _home = isolated_home();
514 let work = tempfile::tempdir().unwrap().keep();
516 let repo = work.join("self_ref");
517 std::fs::create_dir_all(&repo).unwrap();
518 Command::new("git").args(["init", "-q"]).current_dir(&repo).status().unwrap();
519 Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&repo).status().unwrap();
520 Command::new("git").args(["config", "user.name", "t"]).current_dir(&repo).status().unwrap();
521 std::fs::create_dir_all(repo.join(".atomcode-plugin")).unwrap();
522 let url = format!("file://{}", repo.display());
523 let manifest = format!(
524 r#"{{"name":"self_ref","plugins":[{{"name":"self_ref","source":{{"source":"url","url":"{}"}}}}]}}"#,
525 url
526 );
527 std::fs::write(repo.join(".atomcode-plugin/marketplace.json"), manifest).unwrap();
528 std::fs::write(repo.join("README"), "x").unwrap();
529 Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
530 Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&repo).status().unwrap();
531
532 add_marketplace(&url).unwrap();
533 let info = install("self_ref", "self_ref").unwrap();
534
535 assert_eq!(info.plugin_dir, "marketplaces/self_ref");
537 let installed_root = paths::plugins_root().unwrap().join("installed");
538 assert!(
539 !installed_root.exists() || std::fs::read_dir(&installed_root).unwrap().next().is_none(),
540 "dedup should skip the installed/ tree entirely"
541 );
542 }
543
544 #[test]
547 fn dedup_skipped_when_pin_set() {
548 let url = "https://example.com/r.git";
549 let mut pin = GitPin::default();
550 pin.branch = Some("dev".into());
551 let ext = ExternalSource::Url { url: url.into(), pin };
552 assert!(!external_matches_marketplace(&ext, url));
553 }
554
555 #[test]
556 fn normalize_git_url_strips_suffix_and_slash() {
557 assert_eq!(normalize_git_url("https://x/r.git"), "https://x/r");
558 assert_eq!(normalize_git_url("https://x/r/"), "https://x/r");
559 assert_eq!(normalize_git_url("https://x/r.git/"), "https://x/r");
560 assert_eq!(normalize_git_url("https://x/r"), "https://x/r");
561 }
562
563 #[test]
564 fn expand_github_repo_basic() {
565 assert_eq!(
566 expand_github_repo("anthropic/claude").unwrap(),
567 "https://github.com/anthropic/claude.git"
568 );
569 assert_eq!(
570 expand_github_repo("anthropic/claude.git").unwrap(),
571 "https://github.com/anthropic/claude.git"
572 );
573 assert!(expand_github_repo("just-name").is_err());
574 assert!(expand_github_repo("a/b/c").is_err());
575 assert!(expand_github_repo("../etc/passwd").is_err());
576 assert!(expand_github_repo("a/..").is_err());
577 assert!(expand_github_repo("$(rm -rf)/x").is_err());
578 assert!(expand_github_repo("-x/repo").is_err());
580 assert!(expand_github_repo("repo/-x").is_err());
581 }
582
583 #[test]
588 fn dedup_skipped_for_local_source() {
589 let ext = ExternalSource::Local { path: "/tmp/x".into() };
590 assert!(!external_matches_marketplace(&ext, "/tmp/x"));
591 }
592
593 #[test]
594 fn validate_plugin_source_unit() {
595 assert!(validate_plugin_source("").is_ok());
596 assert!(validate_plugin_source("./").is_ok());
597 assert!(validate_plugin_source("plugins/foo").is_ok());
598 assert!(validate_plugin_source("./plugins/foo").is_ok());
599 assert!(validate_plugin_source("../etc").is_err());
600 assert!(validate_plugin_source("plugins/../etc").is_err());
601 assert!(validate_plugin_source("/etc/passwd").is_err());
602 assert!(validate_plugin_source("plugins/foo/../bar").is_err());
603 }
604}