返回 CodeWhale
export.rs
根目录 / crates / tui / src / plugins / export.rs
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
359 lines RUST