use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use super::agent_plugin;
use super::manifest::PluginPathSpec;
use super::path_identity::metadata_is_link_or_reparse;
use super::types::LoadedPlugin;
const MAX_EXPORT_FILES: usize = 4_096;
const EXPORT_EXCLUDED_ROOT_FILES: [&str; 4] = [
agent_plugin::PLUGIN_JSON_NAME,
agent_plugin::PLUGIN_TOML_NAME,
agent_plugin::MCP_JSON_NAME,
crate::skills::install::INSTALLED_FROM_MARKER,
];
#[derive(Debug, Clone)]
pub struct PluginExportReceipt {
pub target: PathBuf,
pub exported_name: String,
pub display_name: Option<String>,
pub wrote_mcp_json: bool,
pub skills_normalized: bool,
pub files_copied: usize,
}
pub fn export_plugin_bundle(
plugin: &LoadedPlugin,
target: &Path,
existing_names: &BTreeSet<String>,
) -> Result<PluginExportReceipt, String> {
let source = &plugin.canonical_root;
if !source.is_dir() {
return Err(format!(
"plugin `{}` bundle root is not a directory: {}",
plugin.name(),
source.display()
));
}
let target_metadata = fs::symlink_metadata(target).ok();
let created = match &target_metadata {
Some(metadata) => {
if metadata_is_link_or_reparse(metadata) || !metadata.is_dir() {
return Err(format!(
"export target must be a real directory, not a link or file: {}",
target.display()
));
}
let mut entries = fs::read_dir(target).map_err(|e| {
format!("failed to inspect export target {}: {e}", target.display())
})?;
if entries.next().is_some() {
return Err(format!(
"export target is not empty: {} (choose a fresh directory)",
target.display()
));
}
false
}
None => true,
};
let canonical_target = canonicalize_with_missing_tail(target)?;
if canonical_target == *source || canonical_target.starts_with(source) {
return Err(format!(
"export target {} must not be the plugin bundle or inside it",
target.display()
));
}
if source.starts_with(&canonical_target) {
return Err(format!(
"export target {} must not contain the plugin bundle",
target.display()
));
}
let skills_normalized = plugin
.manifest
.skills
.as_ref()
.is_some_and(|spec| !agent_plugin::is_default_skills_spec(spec));
let mut manifest = plugin.manifest.clone();
if skills_normalized {
manifest.skills = Some(PluginPathSpec {
path: Some("skills".to_string()),
paths: Vec::new(),
});
}
let emission = agent_plugin::manifest_to_standard(&manifest, existing_names)?;
let plugin_value = serde_json::to_value(&emission.plugin_json)
.map_err(|e| format!("failed to encode plugin.json: {e}"))?;
agent_plugin::validate_plugin_json(&plugin_value)?;
let mcp_value = emission
.mcp_json
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| format!("failed to encode mcp.json: {e}"))?;
if let Some(value) = &mcp_value {
agent_plugin::validate_mcp_json(value)?;
}
if created {
fs::create_dir_all(target)
.map_err(|e| format!("failed to create export target {}: {e}", target.display()))?;
}
let files_copied = match write_export(
source,
target,
&plugin_value,
mcp_value.as_ref(),
&plugin.manifest,
) {
Ok(copied) => copied,
Err(error) => {
if created {
let _ = fs::remove_dir_all(target);
}
return Err(error);
}
};
Ok(PluginExportReceipt {
target: target.to_path_buf(),
exported_name: emission.exported_name,
display_name: emission.display_name,
wrote_mcp_json: mcp_value.is_some(),
skills_normalized,
files_copied,
})
}
fn write_export(
source: &Path,
target: &Path,
plugin_value: &serde_json::Value,
mcp_value: Option<&serde_json::Value>,
source_manifest: &super::manifest::PluginManifest,
) -> Result<usize, String> {
let mut budget = 0_usize;
copy_bundle_tree(source, target, true, &mut budget)?;
normalize_skills_layout(source_manifest, target)?;
write_json_document(&target.join(agent_plugin::PLUGIN_JSON_NAME), plugin_value)?;
if let Some(value) = mcp_value {
write_json_document(&target.join(agent_plugin::MCP_JSON_NAME), value)?;
}
Ok(budget)
}
fn write_json_document(path: &Path, value: &serde_json::Value) -> Result<(), String> {
let mut text = serde_json::to_string_pretty(value)
.map_err(|e| format!("failed to serialize {}: {e}", path.display()))?;
text.push('\n');
fs::write(path, text).map_err(|e| format!("failed to write {}: {e}", path.display()))
}
fn copy_bundle_tree(
source: &Path,
target: &Path,
root_level: bool,
copied: &mut usize,
) -> Result<(), String> {
let mut entries = fs::read_dir(source)
.map_err(|e| format!("failed to read bundle directory {}: {e}", source.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("failed to read bundle directory {}: {e}", source.display()))?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let name = entry.file_name();
if root_level && EXPORT_EXCLUDED_ROOT_FILES.contains(&name.to_string_lossy().as_ref()) {
continue;
}
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|e| format!("failed to inspect bundle entry {}: {e}", path.display()))?;
if metadata_is_link_or_reparse(&metadata) {
return Err(format!(
"plugin bundle contains a symbolic link or reparse point: {}",
path.display()
));
}
let destination = target.join(&name);
if metadata.is_dir() {
fs::create_dir(&destination)
.map_err(|e| format!("failed to create {}: {e}", destination.display()))?;
copy_bundle_tree(&path, &destination, false, copied)?;
} else if metadata.is_file() {
*copied += 1;
if *copied > MAX_EXPORT_FILES {
return Err(format!(
"plugin bundle exceeds the {MAX_EXPORT_FILES}-file export limit"
));
}
fs::copy(&path, &destination).map_err(|e| {
format!(
"failed to copy {} to {}: {e}",
path.display(),
destination.display()
)
})?;
} else {
return Err(format!(
"plugin bundle entry is neither a regular file nor directory: {}",
path.display()
));
}
}
Ok(())
}
fn normalize_skills_layout(
source_manifest: &super::manifest::PluginManifest,
target: &Path,
) -> Result<(), String> {
let Some(spec) = &source_manifest.skills else {
return Ok(());
};
if agent_plugin::is_default_skills_spec(spec) {
return Ok(());
}
let declared: Vec<&String> = spec.path.iter().chain(spec.paths.iter()).collect();
let skills_root = target.join("skills");
for relative in declared {
if relative.as_str() == "skills" {
continue;
}
let source_dir = target.join(relative);
if !source_dir.is_dir() {
return Err(format!(
"declared skills path `{relative}` is missing from the copied bundle"
));
}
if !skills_root.exists() {
fs::create_dir(&skills_root)
.map_err(|e| format!("failed to create {}: {e}", skills_root.display()))?;
}
let mut entries = fs::read_dir(&source_dir)
.map_err(|e| {
format!(
"failed to read skills directory {}: {e}",
source_dir.display()
)
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
format!(
"failed to read skills directory {}: {e}",
source_dir.display()
)
})?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let destination = skills_root.join(entry.file_name());
if destination.exists() {
return Err(format!(
"skills layout normalization collision: `{}` exists in more than one skills path",
entry.file_name().to_string_lossy()
));
}
fs::rename(entry.path(), &destination).map_err(|e| {
format!(
"failed to move skill {} into the standard skills/ tree: {e}",
entry.path().display()
)
})?;
}
let _ = fs::remove_dir(&source_dir);
}
Ok(())
}
fn canonicalize_with_missing_tail(path: &Path) -> Result<PathBuf, String> {
let mut missing = Vec::new();
let mut cursor = path.to_path_buf();
loop {
match cursor.canonicalize() {
Ok(canonical) => {
let mut resolved = canonical;
for component in missing.iter().rev() {
resolved.push(component);
}
return Ok(resolved);
}
Err(_) => {
let Some(file_name) = cursor.file_name().map(|name| name.to_os_string()) else {
return Err(format!(
"export target {} has no existing ancestor",
path.display()
));
};
missing.push(file_name);
cursor = cursor
.parent()
.ok_or_else(|| {
format!("export target {} has no existing ancestor", path.display())
})?
.to_path_buf();
}
}
}
}