| 1 | //! `/plugin export` — publish a loaded plugin as a spec-valid Agent Plugins |
| 2 | //! v1.0.0 bundle. |
| 3 | //! |
| 4 | //! The export writes a fresh directory containing `plugin.json`, `mcp.json` |
| 5 | //! when the plugin declares MCP servers, and the bundle's content tree — |
| 6 | //! including the standard `skills/` layout other clients read. The source |
| 7 | //! bundle is never modified: export is a publish step, not the (separately |
| 8 | //! specified, not yet implemented) on-disk migration. |
| 9 | //! |
| 10 | //! Guarantees: |
| 11 | //! |
| 12 | //! * The target directory is created fresh or must be empty; a failed export |
| 13 | //! removes a directory it created and never touches a pre-existing one. |
| 14 | //! * Every emitted document is re-validated against the standard's shape |
| 15 | //! ([`super::agent_plugin::validate_plugin_json`] / |
| 16 | //! [`super::agent_plugin::validate_mcp_json`]) before it is written. |
| 17 | //! * Symlinks anywhere in the source tree are rejected, matching install |
| 18 | //! staging; the `.installed-from` marker and any manifest files are not |
| 19 | //! copied (fresh manifests are generated). |
| 20 | //! * Skills land at the standard `skills/` root even when the source bundle |
| 21 | //! declared a custom location; colliding skill directory names are an |
| 22 | //! error, never a silent merge. |
| 23 | //! * A name that is invalid under the standard is slugified, the original is |
| 24 | //! preserved as the display name, and a slug that collides with another |
| 25 | //! loaded plugin is an error. |
| 26 | |
| 27 | use std::collections::BTreeSet; |
| 28 | use std::fs; |
| 29 | use std::path::{Path, PathBuf}; |
| 30 | |
| 31 | use super::agent_plugin; |
| 32 | use super::manifest::PluginPathSpec; |
| 33 | use super::path_identity::metadata_is_link_or_reparse; |
| 34 | use super::types::LoadedPlugin; |
| 35 | |
| 36 | /// Mirrors the install staging budget: a validated bundle already fits the |
| 37 | /// review limits, and export refuses to grow unbounded copies regardless. |
| 38 | const MAX_EXPORT_FILES: usize = 4_096; |
| 39 | |
| 40 | /// Files generated fresh (or provenance-local) at the destination; never |
| 41 | /// copied out of the source tree. |
| 42 | const EXPORT_EXCLUDED_ROOT_FILES: [&str; 4] = [ |
| 43 | agent_plugin::PLUGIN_JSON_NAME, |
| 44 | agent_plugin::PLUGIN_TOML_NAME, |
| 45 | agent_plugin::MCP_JSON_NAME, |
| 46 | crate::skills::install::INSTALLED_FROM_MARKER, |
| 47 | ]; |
| 48 | |
| 49 | /// What an export wrote, for the caller to render. |
| 50 | #[derive(Debug, Clone)] |
| 51 | pub struct PluginExportReceipt { |
| 52 | pub target: PathBuf, |
| 53 | /// Name the bundle was published under (post-slugification). |
| 54 | pub exported_name: String, |
| 55 | /// Display name preserved in `extensions["net.codewhale"]` when the |
| 56 | /// published name differs from the source name. |
| 57 | pub display_name: Option<String>, |
| 58 | pub wrote_mcp_json: bool, |
| 59 | pub skills_normalized: bool, |
| 60 | pub files_copied: usize, |
| 61 | } |
| 62 | |
| 63 | /// Publish `plugin` as an Agent Plugins bundle under `target`. |
| 64 | /// |
| 65 | /// `existing_names` must contain every other loaded plugin's name so a |
| 66 | /// slugified name that would collide is an error. |
| 67 | pub fn export_plugin_bundle( |
| 68 | plugin: &LoadedPlugin, |
| 69 | target: &Path, |
| 70 | existing_names: &BTreeSet<String>, |
| 71 | ) -> Result<PluginExportReceipt, String> { |
| 72 | let source = &plugin.canonical_root; |
| 73 | if !source.is_dir() { |
| 74 | return Err(format!( |
| 75 | "plugin `{}` bundle root is not a directory: {}", |
| 76 | plugin.name(), |
| 77 | source.display() |
| 78 | )); |
| 79 | } |
| 80 | |
| 81 | let target_metadata = fs::symlink_metadata(target).ok(); |
| 82 | let created = match &target_metadata { |
| 83 | Some(metadata) => { |
| 84 | if metadata_is_link_or_reparse(metadata) || !metadata.is_dir() { |
| 85 | return Err(format!( |
| 86 | "export target must be a real directory, not a link or file: {}", |
| 87 | target.display() |
| 88 | )); |
| 89 | } |
| 90 | let mut entries = fs::read_dir(target).map_err(|e| { |
| 91 | format!("failed to inspect export target {}: {e}", target.display()) |
| 92 | })?; |
| 93 | if entries.next().is_some() { |
| 94 | return Err(format!( |
| 95 | "export target is not empty: {} (choose a fresh directory)", |
| 96 | target.display() |
| 97 | )); |
| 98 | } |
| 99 | false |
| 100 | } |
| 101 | None => true, |
| 102 | }; |
| 103 | |
| 104 | // Canonicalize the nearest existing ancestor so the containment guards |
| 105 | // hold even when the target itself does not exist yet. |
| 106 | let canonical_target = canonicalize_with_missing_tail(target)?; |
| 107 | if canonical_target == *source || canonical_target.starts_with(source) { |
| 108 | return Err(format!( |
| 109 | "export target {} must not be the plugin bundle or inside it", |
| 110 | target.display() |
| 111 | )); |
| 112 | } |
| 113 | if source.starts_with(&canonical_target) { |
| 114 | return Err(format!( |
| 115 | "export target {} must not contain the plugin bundle", |
| 116 | target.display() |
| 117 | )); |
| 118 | } |
| 119 | |
| 120 | // Normalization decision first: the emitted manifest must describe the |
| 121 | // tree the export actually writes. A custom skills layout is moved to the |
| 122 | // standard `skills/` root below, so the manifest copy used for emission |
| 123 | // always carries the default spec when skills exist at all. |
| 124 | let skills_normalized = plugin |
| 125 | .manifest |
| 126 | .skills |
| 127 | .as_ref() |
| 128 | .is_some_and(|spec| !agent_plugin::is_default_skills_spec(spec)); |
| 129 | let mut manifest = plugin.manifest.clone(); |
| 130 | if skills_normalized { |
| 131 | manifest.skills = Some(PluginPathSpec { |
| 132 | path: Some("skills".to_string()), |
| 133 | paths: Vec::new(), |
| 134 | }); |
| 135 | } |
| 136 | |
| 137 | let emission = agent_plugin::manifest_to_standard(&manifest, existing_names)?; |
| 138 | let plugin_value = serde_json::to_value(&emission.plugin_json) |
| 139 | .map_err(|e| format!("failed to encode plugin.json: {e}"))?; |
| 140 | agent_plugin::validate_plugin_json(&plugin_value)?; |
| 141 | let mcp_value = emission |
| 142 | .mcp_json |
| 143 | .as_ref() |
| 144 | .map(serde_json::to_value) |
| 145 | .transpose() |
| 146 | .map_err(|e| format!("failed to encode mcp.json: {e}"))?; |
| 147 | if let Some(value) = &mcp_value { |
| 148 | agent_plugin::validate_mcp_json(value)?; |
| 149 | } |
| 150 | |
| 151 | if created { |
| 152 | fs::create_dir_all(target) |
| 153 | .map_err(|e| format!("failed to create export target {}: {e}", target.display()))?; |
| 154 | } |
| 155 | let files_copied = match write_export( |
| 156 | source, |
| 157 | target, |
| 158 | &plugin_value, |
| 159 | mcp_value.as_ref(), |
| 160 | &plugin.manifest, |
| 161 | ) { |
| 162 | Ok(copied) => copied, |
| 163 | Err(error) => { |
| 164 | if created { |
| 165 | let _ = fs::remove_dir_all(target); |
| 166 | } |
| 167 | return Err(error); |
| 168 | } |
| 169 | }; |
| 170 | Ok(PluginExportReceipt { |
| 171 | target: target.to_path_buf(), |
| 172 | exported_name: emission.exported_name, |
| 173 | display_name: emission.display_name, |
| 174 | wrote_mcp_json: mcp_value.is_some(), |
| 175 | skills_normalized, |
| 176 | files_copied, |
| 177 | }) |
| 178 | } |
| 179 | |
| 180 | fn write_export( |
| 181 | source: &Path, |
| 182 | target: &Path, |
| 183 | plugin_value: &serde_json::Value, |
| 184 | mcp_value: Option<&serde_json::Value>, |
| 185 | source_manifest: &super::manifest::PluginManifest, |
| 186 | ) -> Result<usize, String> { |
| 187 | let mut budget = 0_usize; |
| 188 | copy_bundle_tree(source, target, true, &mut budget)?; |
| 189 | normalize_skills_layout(source_manifest, target)?; |
| 190 | write_json_document(&target.join(agent_plugin::PLUGIN_JSON_NAME), plugin_value)?; |
| 191 | if let Some(value) = mcp_value { |
| 192 | write_json_document(&target.join(agent_plugin::MCP_JSON_NAME), value)?; |
| 193 | } |
| 194 | Ok(budget) |
| 195 | } |
| 196 | |
| 197 | fn write_json_document(path: &Path, value: &serde_json::Value) -> Result<(), String> { |
| 198 | let mut text = serde_json::to_string_pretty(value) |
| 199 | .map_err(|e| format!("failed to serialize {}: {e}", path.display()))?; |
| 200 | text.push('\n'); |
| 201 | fs::write(path, text).map_err(|e| format!("failed to write {}: {e}", path.display())) |
| 202 | } |
| 203 | |
| 204 | /// Recursively copy the source tree, rejecting links and skipping root-level |
| 205 | /// files that export regenerates (manifests) or that encode local provenance |
| 206 | /// (the install marker). |
| 207 | fn copy_bundle_tree( |
| 208 | source: &Path, |
| 209 | target: &Path, |
| 210 | root_level: bool, |
| 211 | copied: &mut usize, |
| 212 | ) -> Result<(), String> { |
| 213 | let mut entries = fs::read_dir(source) |
| 214 | .map_err(|e| format!("failed to read bundle directory {}: {e}", source.display()))? |
| 215 | .collect::<Result<Vec<_>, _>>() |
| 216 | .map_err(|e| format!("failed to read bundle directory {}: {e}", source.display()))?; |
| 217 | entries.sort_by_key(fs::DirEntry::file_name); |
| 218 | for entry in entries { |
| 219 | let name = entry.file_name(); |
| 220 | if root_level && EXPORT_EXCLUDED_ROOT_FILES.contains(&name.to_string_lossy().as_ref()) { |
| 221 | continue; |
| 222 | } |
| 223 | let path = entry.path(); |
| 224 | let metadata = fs::symlink_metadata(&path) |
| 225 | .map_err(|e| format!("failed to inspect bundle entry {}: {e}", path.display()))?; |
| 226 | if metadata_is_link_or_reparse(&metadata) { |
| 227 | return Err(format!( |
| 228 | "plugin bundle contains a symbolic link or reparse point: {}", |
| 229 | path.display() |
| 230 | )); |
| 231 | } |
| 232 | let destination = target.join(&name); |
| 233 | if metadata.is_dir() { |
| 234 | fs::create_dir(&destination) |
| 235 | .map_err(|e| format!("failed to create {}: {e}", destination.display()))?; |
| 236 | copy_bundle_tree(&path, &destination, false, copied)?; |
| 237 | } else if metadata.is_file() { |
| 238 | *copied += 1; |
| 239 | if *copied > MAX_EXPORT_FILES { |
| 240 | return Err(format!( |
| 241 | "plugin bundle exceeds the {MAX_EXPORT_FILES}-file export limit" |
| 242 | )); |
| 243 | } |
| 244 | fs::copy(&path, &destination).map_err(|e| { |
| 245 | format!( |
| 246 | "failed to copy {} to {}: {e}", |
| 247 | path.display(), |
| 248 | destination.display() |
| 249 | ) |
| 250 | })?; |
| 251 | } else { |
| 252 | return Err(format!( |
| 253 | "plugin bundle entry is neither a regular file nor directory: {}", |
| 254 | path.display() |
| 255 | )); |
| 256 | } |
| 257 | } |
| 258 | Ok(()) |
| 259 | } |
| 260 | |
| 261 | /// Move skills declared at a non-standard location into the standard |
| 262 | /// `skills/` root of the exported tree. Collisions between skill directory |
| 263 | /// names are an error. |
| 264 | fn normalize_skills_layout( |
| 265 | source_manifest: &super::manifest::PluginManifest, |
| 266 | target: &Path, |
| 267 | ) -> Result<(), String> { |
| 268 | let Some(spec) = &source_manifest.skills else { |
| 269 | return Ok(()); |
| 270 | }; |
| 271 | if agent_plugin::is_default_skills_spec(spec) { |
| 272 | return Ok(()); |
| 273 | } |
| 274 | let declared: Vec<&String> = spec.path.iter().chain(spec.paths.iter()).collect(); |
| 275 | let skills_root = target.join("skills"); |
| 276 | for relative in declared { |
| 277 | if relative.as_str() == "skills" { |
| 278 | continue; |
| 279 | } |
| 280 | let source_dir = target.join(relative); |
| 281 | if !source_dir.is_dir() { |
| 282 | return Err(format!( |
| 283 | "declared skills path `{relative}` is missing from the copied bundle" |
| 284 | )); |
| 285 | } |
| 286 | if !skills_root.exists() { |
| 287 | fs::create_dir(&skills_root) |
| 288 | .map_err(|e| format!("failed to create {}: {e}", skills_root.display()))?; |
| 289 | } |
| 290 | let mut entries = fs::read_dir(&source_dir) |
| 291 | .map_err(|e| { |
| 292 | format!( |
| 293 | "failed to read skills directory {}: {e}", |
| 294 | source_dir.display() |
| 295 | ) |
| 296 | })? |
| 297 | .collect::<Result<Vec<_>, _>>() |
| 298 | .map_err(|e| { |
| 299 | format!( |
| 300 | "failed to read skills directory {}: {e}", |
| 301 | source_dir.display() |
| 302 | ) |
| 303 | })?; |
| 304 | entries.sort_by_key(fs::DirEntry::file_name); |
| 305 | for entry in entries { |
| 306 | let destination = skills_root.join(entry.file_name()); |
| 307 | if destination.exists() { |
| 308 | return Err(format!( |
| 309 | "skills layout normalization collision: `{}` exists in more than one skills path", |
| 310 | entry.file_name().to_string_lossy() |
| 311 | )); |
| 312 | } |
| 313 | fs::rename(entry.path(), &destination).map_err(|e| { |
| 314 | format!( |
| 315 | "failed to move skill {} into the standard skills/ tree: {e}", |
| 316 | entry.path().display() |
| 317 | ) |
| 318 | })?; |
| 319 | } |
| 320 | // Remove the declared directory once emptied; leave any deeper |
| 321 | // ancestors (they may hold non-skill content). |
| 322 | let _ = fs::remove_dir(&source_dir); |
| 323 | } |
| 324 | Ok(()) |
| 325 | } |
| 326 | |
| 327 | /// Canonicalize a path whose final component may not exist yet, by resolving |
| 328 | /// the nearest existing ancestor. |
| 329 | fn canonicalize_with_missing_tail(path: &Path) -> Result<PathBuf, String> { |
| 330 | let mut missing = Vec::new(); |
| 331 | let mut cursor = path.to_path_buf(); |
| 332 | loop { |
| 333 | match cursor.canonicalize() { |
| 334 | Ok(canonical) => { |
| 335 | let mut resolved = canonical; |
| 336 | for component in missing.iter().rev() { |
| 337 | resolved.push(component); |
| 338 | } |
| 339 | return Ok(resolved); |
| 340 | } |
| 341 | Err(_) => { |
| 342 | let Some(file_name) = cursor.file_name().map(|name| name.to_os_string()) else { |
| 343 | return Err(format!( |
| 344 | "export target {} has no existing ancestor", |
| 345 | path.display() |
| 346 | )); |
| 347 | }; |
| 348 | missing.push(file_name); |
| 349 | cursor = cursor |
| 350 | .parent() |
| 351 | .ok_or_else(|| { |
| 352 | format!("export target {} has no existing ancestor", path.display()) |
| 353 | })? |
| 354 | .to_path_buf(); |
| 355 | } |
| 356 | } |
| 357 | } |
| 358 | } |
| 359 |