1use std::collections::{HashMap, HashSet};
32
33use serde::Serialize;
34use serde_json::{json, Value};
35
36use super::agent_config::{render_skill_md, unique_skill_slug, SKILL_TYPE_AGENT_SKILL};
37use super::storage::store::{derive_slug, Bundle};
38use super::storage::Skill;
39
40#[derive(Debug, Clone, Serialize, PartialEq, Eq, ts_rs::TS)]
43#[ts(export, export_to = "../../../frontend/types/rpc/")]
44pub struct BundleExportFile {
45 pub path: String,
46 pub content: String,
47}
48
49#[derive(Debug, Clone, Serialize)]
53pub struct BundleExport {
54 pub root_slug: String,
57 pub files: Vec<BundleExportFile>,
58 pub skipped_skills: Vec<String>,
64 pub warnings: Vec<String>,
73}
74
75#[derive(Debug, serde::Deserialize, Default)]
79pub(crate) struct ContextFileEntry {
80 #[serde(default)]
81 pub(crate) path: String,
82 #[serde(default)]
83 pub(crate) content: String,
84}
85
86pub(crate) fn sanitize_context_relative_path(path: &str) -> Option<String> {
99 if path.is_empty() {
100 return None;
101 }
102 let normalized = path.replace('\\', "/");
103 if normalized.starts_with('/') || normalized.contains(':') {
104 return None;
105 }
106 let mut parts = Vec::new();
107 for component in normalized.split('/') {
108 match component {
109 "" | "." => continue,
110 ".." => return None,
111 other => parts.push(other),
112 }
113 }
114 if parts.is_empty() {
115 return None;
116 }
117 Some(parts.join("/"))
118}
119
120pub fn export_bundle(bundle: &Bundle, skills: &[Skill]) -> BundleExport {
129 let root_slug = derive_slug(&bundle.name);
130 let mut files = Vec::new();
131 let mut skipped_skills = Vec::new();
132 let mut warnings = Vec::new();
133
134 let mut manifest_instructions: Vec<String> = Vec::new();
135 let mut manifest_skills: Vec<String> = Vec::new();
136
137 if !bundle.instructions.trim().is_empty() {
142 files.push(BundleExportFile {
143 path: "instructions/AGENTS.md".to_string(),
144 content: bundle.instructions.clone(),
145 });
146 manifest_instructions.push("instructions/AGENTS.md".to_string());
147 }
148
149 let instructions_by_provider: HashMap<String, String> = parse_json_field_or_warn(
150 &bundle.instructions_by_provider,
151 "instructions_by_provider",
152 &mut warnings,
153 );
154 let mut manifest_instructions_by_provider: std::collections::BTreeMap<String, Vec<String>> =
159 std::collections::BTreeMap::new();
160 let mut sorted_providers: Vec<(&String, &String)> = instructions_by_provider.iter().collect();
170 sorted_providers.sort_by(|a, b| a.0.cmp(b.0));
171 let mut seen_safe_providers: HashSet<String> = HashSet::new();
172 for (provider, content) in sorted_providers {
173 if content.trim().is_empty() {
174 continue;
175 }
176 let Some(safe_provider) = sanitize_context_relative_path(provider) else {
183 warnings.push(format!(
184 "instructions_by_provider: \"{provider}\" is not a safe path segment; skipped"
185 ));
186 continue;
187 };
188 if !seen_safe_providers.insert(safe_provider.clone()) {
189 warnings.push(format!(
190 "instructions_by_provider: \"{provider}\" normalizes to the same path as an earlier key (instructions/{safe_provider}/AGENTS.md); skipped to avoid overwriting it"
191 ));
192 continue;
193 }
194 let out_path = format!("instructions/{safe_provider}/AGENTS.md");
195 files.push(BundleExportFile {
196 path: out_path.clone(),
197 content: content.clone(),
198 });
199 manifest_instructions_by_provider
200 .entry(safe_provider)
201 .or_default()
202 .push(out_path);
203 }
204
205 let context_files: Vec<ContextFileEntry> = parse_json_field_or_warn(
206 &bundle.context_files,
207 "context_files",
208 &mut warnings,
209 );
210 let mut used_context_paths: HashSet<String> = HashSet::new();
217 for entry in context_files {
218 if let Some(safe_path) = sanitize_context_relative_path(&entry.path) {
219 let out_path = format!("instructions/context/{safe_path}");
220 if !used_context_paths.insert(out_path.to_lowercase()) {
226 warnings.push(format!(
227 "context_files: \"{}\" normalizes to the same path as an \
228 earlier entry ({out_path}); skipped to avoid overwriting it",
229 entry.path
230 ));
231 continue;
232 }
233 manifest_instructions.push(out_path.clone());
234 files.push(BundleExportFile {
235 path: out_path,
236 content: entry.content,
237 });
238 } else {
239 warnings.push(format!(
246 "context_files: \"{}\" is not a safe relative path (absolute, \
247 contains \"..\", a drive letter, or empty); skipped",
248 entry.path
249 ));
250 }
251 }
252
253 let mut used_skill_slugs: HashSet<String> = HashSet::new();
257 for skill in skills {
258 if skill.skill_type != SKILL_TYPE_AGENT_SKILL {
259 skipped_skills.push(skill.name.clone());
260 continue;
261 }
262 if skill.content.is_empty() {
263 skipped_skills.push(skill.name.clone());
264 continue;
265 }
266 let slug = unique_skill_slug(&skill.name, &mut used_skill_slugs);
267 files.push(BundleExportFile {
268 path: format!("skills/{slug}/SKILL.md"),
269 content: render_skill_md(&slug, &skill.description, &skill.content),
270 });
271 manifest_skills.push(format!("skills/{slug}"));
277 }
278
279 let mut components = serde_json::Map::new();
280 if !manifest_instructions.is_empty() || !manifest_instructions_by_provider.is_empty() {
286 let mut instructions_obj = serde_json::Map::new();
287 if !manifest_instructions.is_empty() {
288 instructions_obj.insert("default".to_string(), json!(manifest_instructions));
289 }
290 for (provider, paths) in &manifest_instructions_by_provider {
291 instructions_obj.insert(provider.clone(), json!(paths));
292 }
293 components.insert("instructions".to_string(), Value::Object(instructions_obj));
294 }
295 if !manifest_skills.is_empty() {
296 components.insert("skills".to_string(), json!(manifest_skills));
297 }
298
299
300 let mut suggested_for = serde_json::Map::new();
304 if !bundle.provider.is_empty() {
305 suggested_for.insert("provider".into(), json!(bundle.provider));
306 }
307 if !bundle.model.is_empty() {
308 suggested_for.insert("vendor".into(), json!(bundle.model));
309 }
310 let mut manifest = json!({
311 "$schema": crate::backend::bundle_import::SCHEMA_V0_3,
314 "name": root_slug,
315 "version": "0.1.0",
319 "description": bundle.description,
320 "components": Value::Object(components),
321 "metadata": {},
322 });
323 if !suggested_for.is_empty() {
324 manifest["suggestedFor"] = Value::Object(suggested_for);
325 }
326 files.push(BundleExportFile {
327 path: crate::backend::bundle_import::MANIFEST_FILE.to_string(),
328 content: serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| "{}".to_string()),
329 });
330
331 BundleExport {
332 root_slug,
333 files,
334 skipped_skills,
335 warnings,
336 }
337}
338
339pub(crate) fn parse_json_field_or_warn<T: serde::de::DeserializeOwned + Default>(
346 raw: &str,
347 field_name: &str,
348 warnings: &mut Vec<String>,
349) -> T {
350 if raw.trim().is_empty() {
351 return T::default();
352 }
353 match serde_json::from_str(raw) {
354 Ok(v) => v,
355 Err(e) => {
356 warnings.push(format!("{field_name}: malformed JSON, treated as empty ({e})"));
357 T::default()
358 }
359 }
360}
361
362pub fn zip_bundle_export(export: &BundleExport) -> Result<Vec<u8>, String> {
368 use std::io::Write;
369 use zip::write::SimpleFileOptions;
370
371 let mut buf = std::io::Cursor::new(Vec::new());
372 let mut writer = zip::ZipWriter::new(&mut buf);
373 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
374
375 for file in &export.files {
376 let zip_path = format!("{}/{}", export.root_slug, file.path);
377 writer
378 .start_file(zip_path, options)
379 .map_err(|e| format!("zip_bundle_export: {e}"))?;
380 writer
381 .write_all(file.content.as_bytes())
382 .map_err(|e| format!("zip_bundle_export: {e}"))?;
383 }
384
385 writer
386 .finish()
387 .map_err(|e| format!("zip_bundle_export: {e}"))?;
388 Ok(buf.into_inner())
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn make_bundle(instructions: &str, context_files: &str, mcp_servers: &str, skills: &str) -> Bundle {
396 Bundle {
397 id: "bundle-1".to_string(),
398 name: "Backend Dev Bundle".to_string(),
399 description: "Backend dev conventions".to_string(),
400 is_blank: false,
401 is_global: false,
402 provider: String::new(),
403 model: String::new(),
404 instructions: instructions.to_string(),
405 instructions_by_provider: "{}".to_string(),
406 context_files: context_files.to_string(),
407 mcp_servers: mcp_servers.to_string(),
408 skills: skills.to_string(),
409 sort_order: 0,
410 created_at: 1_700_000_000_000,
411 updated_at: 1_700_000_000_000,
412 is_system: false,
413 }
414 }
415
416 fn make_agent_skill(name: &str) -> Skill {
417 Skill {
418 id: format!("skill-{name}"),
419 name: name.to_string(),
420 trigger: String::new(),
421 skill_type: "agent-skill".to_string(),
422 description: format!("{name} description"),
423 content: format!("{name} content"),
424 is_global: true,
425 created_at: 1_700_000_000_000,
426 updated_at: 1_700_000_000_000,
427 }
428 }
429
430 #[test]
431 fn exports_instructions_as_agents_md() {
432 let bundle = make_bundle("Follow repo conventions.", "[]", "[]", "[]");
433 let export = export_bundle(&bundle, &[]);
434 let f = export.files.iter().find(|f| f.path == "instructions/AGENTS.md").unwrap();
435 assert_eq!(f.content, "Follow repo conventions.");
436 }
437
438 #[test]
439 fn exports_context_files_under_instructions_context() {
440 let context_files = r#"[{"path":"docs/readme.md","content":"Readme heading"}]"#;
441 let bundle = make_bundle("", context_files, "[]", "[]");
442 let export = export_bundle(&bundle, &[]);
443 let f = export
444 .files
445 .iter()
446 .find(|f| f.path == "instructions/context/docs/readme.md")
447 .expect("expected instructions/context/docs/readme.md");
448 assert_eq!(f.content, "Readme heading");
449 }
450
451 #[test]
452 fn rejects_path_traversal_in_context_files() {
453 let context_files = r#"[{"path":"../../etc/passwd","content":"evil"}]"#;
454 let bundle = make_bundle("", context_files, "[]", "[]");
455 let export = export_bundle(&bundle, &[]);
456 assert!(export.files.iter().all(|f| !f.content.contains("evil")));
457 assert!(!export.files.iter().any(|f| f.path.contains("..")));
458 assert!(
462 export.warnings.iter().any(|w| w.contains("../../etc/passwd")),
463 "expected a warning naming the rejected path, got: {:?}",
464 export.warnings
465 );
466 }
467
468 #[test]
469 fn exports_agent_skill_format_skills_and_skips_prompt_format() {
470 let bundle = make_bundle("", "[]", "[]", r#"["skill-a","skill-b"]"#);
471 let mut prompt_skill = make_agent_skill("Slash Skill");
472 prompt_skill.skill_type = "prompt".to_string();
473 let skills = vec![make_agent_skill("Deploy Checklist"), prompt_skill];
474
475 let export = export_bundle(&bundle, &skills);
476 assert!(export
477 .files
478 .iter()
479 .any(|f| f.path == "skills/deploy-checklist/SKILL.md"));
480 assert!(!export.files.iter().any(|f| f.path.contains("slash-skill")));
481 assert_eq!(export.skipped_skills, vec!["Slash Skill".to_string()]);
482 }
483
484 #[test]
485 fn malformed_context_files_json_warns_instead_of_silently_dropping_data() {
486 let bundle = make_bundle("", "{not valid json", "[]", "[]");
491 let export = export_bundle(&bundle, &[]);
492 assert!(
493 export.warnings.iter().any(|w| w.contains("context_files") && w.contains("malformed")),
494 "expected a warning about malformed context_files, got: {:?}",
495 export.warnings
496 );
497 }
498
499 #[test]
500 fn blank_context_files_produce_no_warning() {
501 let bundle = make_bundle("", "", "", "[]");
503 let export = export_bundle(&bundle, &[]);
504 assert!(export.warnings.is_empty(), "blank fields must not warn: {:?}", export.warnings);
505 }
506
507 #[test]
508 fn parse_json_field_or_warn_direct_unit_test() {
509 let mut warnings = Vec::new();
515 let blank: Vec<String> = parse_json_field_or_warn("", "skills", &mut warnings);
516 assert!(blank.is_empty());
517 assert!(warnings.is_empty(), "blank must not warn");
518
519 let malformed: Vec<String> = parse_json_field_or_warn("not json", "skills", &mut warnings);
520 assert!(malformed.is_empty());
521 assert_eq!(warnings.len(), 1);
522 assert!(warnings[0].contains("skills") && warnings[0].contains("malformed"));
523
524 let mut warnings2 = Vec::new();
525 let valid: Vec<String> = parse_json_field_or_warn(r#"["a","b"]"#, "skills", &mut warnings2);
526 assert_eq!(valid, vec!["a".to_string(), "b".to_string()]);
527 assert!(warnings2.is_empty());
528 }
529
530 #[test]
531 fn colliding_context_file_paths_are_deduped_with_a_warning() {
532 let context_files = r#"[
536 {"path":"docs/a.md","content":"first"},
537 {"path":"docs/./a.md","content":"second, would silently clobber the first"}
538 ]"#;
539 let bundle = make_bundle("", context_files, "[]", "[]");
540 let export = export_bundle(&bundle, &[]);
541
542 let matches: Vec<_> = export
543 .files
544 .iter()
545 .filter(|f| f.path == "instructions/context/docs/a.md")
546 .collect();
547 assert_eq!(matches.len(), 1, "must not produce duplicate file entries for the same path");
548 assert_eq!(matches[0].content, "first", "the first entry must win, not be silently overwritten");
549 assert!(
550 export.warnings.iter().any(|w| w.contains("docs/./a.md")),
551 "expected a warning naming the skipped duplicate, got: {:?}",
552 export.warnings
553 );
554 }
555
556 #[test]
557 fn colliding_context_file_paths_case_insensitive() {
558 let context_files = r#"[
564 {"path":"Docs/A.md","content":"first"},
565 {"path":"docs/a.md","content":"second, would collide on a case-insensitive filesystem"}
566 ]"#;
567 let bundle = make_bundle("", context_files, "[]", "[]");
568 let export = export_bundle(&bundle, &[]);
569
570 let matches: Vec<_> = export
571 .files
572 .iter()
573 .filter(|f| f.path.to_lowercase() == "instructions/context/docs/a.md")
574 .collect();
575 assert_eq!(matches.len(), 1, "must not produce case-only-different duplicate entries");
576 assert_eq!(matches[0].content, "first");
577 assert!(export.warnings.iter().any(|w| w.contains("docs/a.md")));
578 }
579
580 #[test]
581 fn manifest_lists_every_component_and_validates_as_json() {
582 let bundle = make_bundle(
583 "Instructions",
584 r#"[{"path":"a.md","content":"A"}]"#,
585 r#"[{"name":"github","env":{"GITHUB_TOKEN":""}}]"#,
586 r#"["skill-a"]"#,
587 );
588 let export = export_bundle(&bundle, &[make_agent_skill("Deploy")]);
589 let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
590 let manifest: Value = serde_json::from_str(&manifest_file.content).expect("bundle.json must be valid JSON");
591 assert_eq!(manifest["name"], "backend-dev-bundle");
592 assert!(manifest["components"]["instructions"]["default"].as_array().unwrap().len() == 2);
595 assert!(manifest["components"]["skills"].as_array().unwrap().len() == 1);
596 }
597
598 #[test]
602 fn exports_no_mcp_servers_and_no_inferred_requirements() {
603 let bundle = make_bundle("", "[]", r#"[{"name":"github","env":{"GITHUB_TOKEN":"ghp_x"}}]"#, "[]");
604 let export = export_bundle(&bundle, &[]);
605 assert!(!export.files.iter().any(|f| f.path.starts_with("mcp/") || f.path.starts_with("accounts/")));
606 let manifest: Value = serde_json::from_str(&export.files.iter().find(|f| f.path == "bundle.json").unwrap().content).unwrap();
607 assert!(manifest["components"].get("mcpServers").is_none());
608 assert!(manifest["components"].get("accounts").is_none());
609 assert!(!export.files.iter().any(|f| f.content.contains("ghp_x")));
610 }
611
612 #[test]
613 fn exports_a_provider_scoped_instruction_variant() {
614 let mut bundle = make_bundle("Default instructions.", "[]", "[]", "[]");
615 bundle.instructions_by_provider =
616 r#"{"claude":"Claude-specific override.","codex":"Codex-specific override."}"#.to_string();
617 let export = export_bundle(&bundle, &[]);
618
619 let claude_file = export.files.iter().find(|f| f.path == "instructions/claude/AGENTS.md")
620 .expect("expected instructions/claude/AGENTS.md");
621 assert_eq!(claude_file.content, "Claude-specific override.");
622 let codex_file = export.files.iter().find(|f| f.path == "instructions/codex/AGENTS.md")
623 .expect("expected instructions/codex/AGENTS.md");
624 assert_eq!(codex_file.content, "Codex-specific override.");
625
626 let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
627 let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
628 assert_eq!(manifest["components"]["instructions"]["default"], json!(["instructions/AGENTS.md"]));
629 assert_eq!(manifest["components"]["instructions"]["claude"], json!(["instructions/claude/AGENTS.md"]));
630 assert_eq!(manifest["components"]["instructions"]["codex"], json!(["instructions/codex/AGENTS.md"]));
631 }
632
633 #[test]
634 fn a_blank_provider_variant_is_omitted_entirely() {
635 let mut bundle = make_bundle("Default.", "[]", "[]", "[]");
639 bundle.instructions_by_provider = r#"{"claude":" "}"#.to_string();
640 let export = export_bundle(&bundle, &[]);
641 assert!(!export.files.iter().any(|f| f.path.starts_with("instructions/claude/")));
642 let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
643 let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
644 assert!(manifest["components"]["instructions"].get("claude").is_none());
645 }
646
647 #[test]
648 fn colliding_provider_keys_after_sanitization_do_not_overwrite_each_other() {
649 let mut bundle = make_bundle("Default.", "[]", "[]", "[]");
654 bundle.instructions_by_provider =
655 r#"{"claude":"First.","./claude":"Second."}"#.to_string();
656 let export = export_bundle(&bundle, &[]);
657
658 let claude_files: Vec<_> = export.files.iter().filter(|f| f.path == "instructions/claude/AGENTS.md").collect();
659 assert_eq!(claude_files.len(), 1, "must not produce two files at the same path");
660 assert_eq!(claude_files[0].content, "Second.");
665
666 let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
667 let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
668 assert_eq!(
669 manifest["components"]["instructions"]["claude"],
670 json!(["instructions/claude/AGENTS.md"]),
671 "must not list the same path twice"
672 );
673 assert!(export.warnings.iter().any(|w| w.contains("normalizes to the same path")));
674 }
675
676 #[test]
677 fn manifest_references_the_skill_directory_not_the_skill_md_file() {
678 let bundle = make_bundle("", "[]", "[]", r#"["skill-a"]"#);
681 let export = export_bundle(&bundle, &[make_agent_skill("Deploy Checklist")]);
682 let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").unwrap();
683 let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
684 let skills = manifest["components"]["skills"].as_array().unwrap();
685 assert_eq!(skills, &vec![json!("skills/deploy-checklist")]);
686 assert!(export.files.iter().any(|f| f.path == "skills/deploy-checklist/SKILL.md"));
688 }
689
690 #[test]
691 fn writes_an_abf_v0_3_manifest_with_a_suggested_for_hint() {
692 let mut bundle = make_bundle("Be concise.", "[]", "[]", "[]");
693 bundle.provider = "codex".into();
694 bundle.model = "openai".into();
695 let export = export_bundle(&bundle, &[]);
696 let manifest_file = export.files.iter().find(|f| f.path == "bundle.json").expect("bundle.json");
697 assert!(!export.files.iter().any(|f| f.path == "armory.json"));
698 let manifest: Value = serde_json::from_str(&manifest_file.content).unwrap();
699 assert_eq!(manifest["$schema"], "https://docs.agentmux.ai/schemas/agent-bundle/v0.3/bundle.schema.json");
700 assert_eq!(manifest["suggestedFor"], json!({ "provider": "codex", "vendor": "openai" }));
701 assert!(manifest.get("provider").is_none() && manifest.get("model").is_none());
702 }
703
704 #[test]
705 fn omits_the_hint_when_the_bundle_has_none() {
706 let export = export_bundle(&make_bundle("x", "[]", "[]", "[]"), &[]);
707 let manifest: Value = serde_json::from_str(&export.files.iter().find(|f| f.path == "bundle.json").unwrap().content).unwrap();
708 assert!(manifest.get("suggestedFor").is_none());
709 }
710
711 #[test]
712 fn a_v0_3_export_round_trips_through_import() {
713 let mut bundle = make_bundle("Be concise.", "[]", "[]", "[]");
714 bundle.provider = "gemini".into();
715 bundle.model = "google".into();
716 let export = export_bundle(&bundle, &[]);
717 let files: Vec<crate::backend::bundle_import::BundleImportFile> = export
718 .files
719 .iter()
720 .map(|f| crate::backend::bundle_import::BundleImportFile { path: f.path.clone(), content: f.content.clone() })
721 .collect();
722 let parsed = crate::backend::bundle_import::parse_bundle_import(&files).unwrap();
723 assert_eq!((parsed.provider.as_str(), parsed.model.as_str()), ("gemini", "google"));
724 assert!(parsed.instructions.contains("Be concise."));
725 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
726 }
727
728 #[test]
729 fn empty_bundle_still_produces_a_valid_manifest() {
730 let bundle = make_bundle("", "[]", "[]", "[]");
731 let export = export_bundle(&bundle, &[]);
732 assert_eq!(export.files.len(), 1);
734 let manifest: Value = serde_json::from_str(&export.files[0].content).unwrap();
735 assert_eq!(manifest["components"], json!({}));
736 }
737
738 #[test]
739 fn zip_bundle_export_produces_a_valid_archive_with_all_files() {
740 let bundle = make_bundle("Instructions here", "[]", "[]", "[]");
741 let export = export_bundle(&bundle, &[]);
742 let zip_bytes = zip_bundle_export(&export).expect("zip should succeed");
743
744 let cursor = std::io::Cursor::new(zip_bytes);
745 let mut archive = zip::ZipArchive::new(cursor).expect("valid zip archive");
746 assert_eq!(archive.len(), export.files.len());
747
748 let mut found = false;
749 for i in 0..archive.len() {
750 let file = archive.by_index(i).unwrap();
751 if file.name() == "backend-dev-bundle/bundle.json" {
752 found = true;
753 }
754 }
755 assert!(found, "expected backend-dev-bundle/bundle.json in the archive");
756 }
757}