1use super::installer::{ContextPackage, Installer, markdown_files};
5#[cfg(test)]
6use super::test_filesystem as filesystem;
7use bake::{Error, Result};
8use serde::{Deserialize, Serialize};
9use socketry_markdown::{ParseOptions, mdast::Node, to_mdast};
10use std::cmp::Ordering as Comparison;
11use std::collections::{BTreeMap, HashMap, HashSet};
12#[cfg(not(test))]
13use std::fs as filesystem;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
16
17const REGISTRY_VERSION: u32 = 1;
18const REGISTRY_FILE: &str = ".agent-context-skills.json";
19static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
20
21#[derive(Clone, Debug)]
23pub struct Skill {
24 pub name: String,
26 pub description: String,
27 pub package: ContextPackage,
28 pub(crate) source_name: String,
29 assets: Option<PathBuf>,
30 body: String,
31}
32
33impl Skill {
34 pub fn package_selector(&self) -> &str {
36 self.package.selector()
37 }
38}
39
40#[derive(Deserialize)]
41#[serde(deny_unknown_fields)]
42struct ContextFrontmatter {
43 #[serde(rename = "type")]
44 document_type: Option<String>,
45 description: Option<String>,
46}
47
48#[derive(Serialize)]
49struct SkillFrontmatter<'a> {
50 name: &'a str,
51 description: &'a str,
52}
53
54#[derive(Clone, Debug, Deserialize, Serialize)]
55struct Registry {
56 version: u32,
57 skills: BTreeMap<String, SkillOwner>,
58}
59
60#[derive(Clone, Debug, Deserialize, Serialize)]
61struct SkillOwner {
62 package: String,
63 version: String,
64}
65
66impl Default for Registry {
67 fn default() -> Self {
68 Self {
69 version: REGISTRY_VERSION,
70 skills: BTreeMap::new(),
71 }
72 }
73}
74
75pub fn list_skills(installer: &Installer, package: Option<&str>) -> Result<Vec<Skill>> {
77 let packages = if let Some(selector) = package {
78 let Some(package) = installer.find_package(selector)? else {
79 return Ok(Vec::new());
80 };
81 vec![package]
82 } else {
83 installer.packages().to_vec()
84 };
85
86 let mut skills = Vec::new();
87 for package in packages {
88 skills.extend(list_package_skills(&package)?);
89 }
90
91 skills.sort_by(compare_skills);
92 Ok(skills)
93}
94
95fn compare_skills(left: &Skill, right: &Skill) -> Comparison {
96 let package_order = left.package.name.cmp(&right.package.name);
97 if package_order != Comparison::Equal {
98 return package_order;
99 }
100
101 let version_order = left.package.version.cmp(&right.package.version);
102 if version_order != Comparison::Equal {
103 return version_order;
104 }
105
106 left.name.cmp(&right.name)
107}
108
109pub(crate) fn list_package_skills(package: &ContextPackage) -> Result<Vec<Skill>> {
110 let mut skills = Vec::new();
111 let mut files = markdown_files(&package.context_path)?;
112 files.sort();
113
114 for source in files {
115 let contents = filesystem::read_to_string(&source)
116 .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?;
117 if let Some(skill) = parse_skill_document(package, &source, &contents)? {
118 skills.push(skill);
119 }
120 }
121
122 Ok(skills)
123}
124
125fn parse_skill_document(
126 package: &ContextPackage,
127 source: &Path,
128 contents: &str,
129) -> Result<Option<Skill>> {
130 let mut options = ParseOptions::default();
131 options.constructs.frontmatter = true;
132 let mut document =
134 to_mdast(contents, &options).expect("Markdown parsing without MDX support is infallible");
135
136 let Some(frontmatter) = context_frontmatter(&document, source)? else {
137 return Ok(None);
138 };
139 let Some(document_type) = frontmatter.document_type else {
140 return Ok(None);
141 };
142 if document_type != "skill" {
143 return Err(Error::new(format!(
144 "unsupported context type {document_type:?} in {}; supported type: skill",
145 source.display()
146 )));
147 }
148
149 if source.parent() != Some(package.context_path.as_path()) {
150 return Err(Error::new(format!(
151 "skill document {} must be directly inside context/",
152 source.display()
153 )));
154 }
155
156 let source_name = source_name(source)?;
157 validate_skill_name(&source_name)?;
158
159 let package_prefix = package.name.to_ascii_lowercase().replace('_', "-");
160 let name = format!("{package_prefix}-{source_name}");
161 validate_skill_name(&name)?;
162
163 let description = frontmatter
164 .description
165 .map(|description| description.trim().to_owned())
166 .filter(|description| !description.is_empty())
167 .ok_or_else(|| {
168 Error::new(format!(
169 "skill {} in crate {} requires a non-empty `description`",
170 name, package.name
171 ))
172 })?;
173 if description.chars().count() > 1024 {
174 return Err(Error::new(format!(
175 "skill description for {name:?} exceeds the 1024 character limit"
176 )));
177 }
178
179 let assets = skill_assets_path(&package.context_path, &source_name)?;
180 let body = skill_body(&mut document);
181
182 Ok(Some(Skill {
183 name,
184 description,
185 package: package.clone(),
186 source_name,
187 assets,
188 body,
189 }))
190}
191
192fn skill_assets_path(context_path: &Path, source_name: &str) -> Result<Option<PathBuf>> {
193 let assets = context_path.join(source_name);
194 match filesystem::symlink_metadata(&assets) {
195 Ok(metadata) if metadata.file_type().is_dir() => Ok(Some(assets)),
196 Ok(_) => Err(Error::new(format!(
197 "skill assets path {} is not a directory",
198 assets.display()
199 ))),
200 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
201 Err(error) => Err(Error::new(format!(
202 "cannot inspect skill assets {}: {error}",
203 assets.display()
204 ))),
205 }
206}
207
208fn skill_body(document: &mut Node) -> String {
209 let children = document
212 .children_mut()
213 .expect("a Markdown document root always has children");
214 children.remove(0);
215 document.to_markdown()
216}
217
218pub fn install_skills(
224 installer: &Installer,
225 package_selector: Option<&str>,
226 skill_name: Option<&str>,
227) -> Result<Vec<String>> {
228 let (selected_package, mut skills) = if let Some(selector) = package_selector {
232 let Some(package) = installer.find_package(selector)? else {
233 return Err(Error::new(format!(
234 "no context found for crate {selector:?}"
235 )));
236 };
237 let skills = list_package_skills(&package)?;
238 (Some(package), skills)
239 } else {
240 (None, list_skills(installer, None)?)
241 };
242
243 if let Some(skill_name) = skill_name {
244 skills.retain(|skill| skill.name == skill_name);
245 if skills.is_empty() {
246 return Err(Error::new(format!(
247 "no dependency skill named {skill_name:?} was found"
248 )));
249 }
250 }
251
252 if let Some(skill_name) = skill_name
253 && skills.len() > 1
254 {
255 let packages = skills
256 .iter()
257 .map(|skill| skill.package_selector())
258 .collect::<Vec<_>>()
259 .join(", ");
260 return Err(Error::new(format!(
261 "skill {skill_name:?} is provided by multiple crates ({packages}); select one with --package"
262 )));
263 }
264
265 let mut selected_names = HashSet::new();
266 for skill in &skills {
267 if !selected_names.insert(skill.name.as_str()) {
268 return Err(Error::new(format!(
269 "multiple selected crates provide skill {:?}; select one with --package",
270 skill.name
271 )));
272 }
273 }
274
275 let reconcile_all = package_selector.is_none() && skill_name.is_none();
276 let reconcile_package = if skill_name.is_none() {
277 selected_package
278 .as_ref()
279 .map(|package| package.name.as_str())
280 } else {
281 None
282 };
283
284 let skills_root = installer.root().join(".agents/skills");
285 ensure_directory(&skills_root)?;
286 let registry_path = skills_root.join(REGISTRY_FILE);
287 let (mut registry, had_registry) = load_registry_with_existence(®istry_path)?;
288
289 let selected_by_name: HashMap<_, _> = skills
290 .iter()
291 .map(|skill| (skill.name.as_str(), skill))
292 .collect();
293
294 let mut destination_exists = HashMap::new();
295 for skill in &skills {
296 if let Some(owner) = registry.skills.get(&skill.name)
297 && owner.package != skill.package.name
298 {
299 return Err(Error::new(format!(
300 "skill {:?} is already installed from crate {:?}; it cannot be replaced by {:?}",
301 skill.name, owner.package, skill.package.name
302 )));
303 }
304
305 let destination = skills_root.join(&skill.name);
306 let exists = path_exists(&destination)?;
307 destination_exists.insert(skill.name.clone(), exists);
308 if exists
309 && registry
310 .skills
311 .get(&skill.name)
312 .is_none_or(|owner| owner.package != skill.package.name)
313 {
314 return Err(Error::new(format!(
315 "skill destination {} already exists and is not managed by Bake Agent Context",
316 destination.display()
317 )));
318 }
319 }
320
321 let stale_skills: Vec<_> = registry
322 .skills
323 .iter()
324 .filter(|(name, owner)| {
325 !selected_by_name.contains_key(name.as_str())
326 && (reconcile_all
327 || match reconcile_package {
328 Some(package) => owner.package == package,
329 None => false,
330 })
331 })
332 .map(|(name, _)| name.clone())
333 .collect();
334 let mut stale_exists = HashMap::new();
335 for name in &stale_skills {
336 stale_exists.insert(name.clone(), path_exists(&skills_root.join(name))?);
337 }
338
339 for name in &stale_skills {
340 registry.skills.remove(name);
341 }
342 for skill in &skills {
343 registry.skills.insert(
344 skill.name.clone(),
345 SkillOwner {
346 package: skill.package.name.clone(),
347 version: skill.package.version.clone(),
348 },
349 );
350 }
351 let encoded_registry = serde_json::to_vec_pretty(®istry)
352 .expect("the skill registry contains only serializable values");
353 let exclude_update =
354 super::exclude::prepare(installer.root(), registry.skills.keys().cloned())?;
355
356 let stage = staging_path(&skills_root);
357 filesystem::create_dir(&stage)
358 .map_err(|error| Error::new(format!("cannot create {}: {error}", stage.display())))?;
359 let new_skills = stage.join("new");
360 let backups = stage.join("backups");
361 if let Err(error) =
362 filesystem::create_dir(&new_skills).and_then(|_| filesystem::create_dir(&backups))
363 {
364 let _ = filesystem::remove_dir_all(&stage);
365 return Err(Error::new(format!(
366 "cannot prepare {}: {error}",
367 stage.display()
368 )));
369 }
370
371 for skill in &skills {
372 let result = write_staged_skill(skill, &new_skills.join(&skill.name));
373 if let Err(error) = result {
374 let _ = filesystem::remove_dir_all(&stage);
375 return Err(error);
376 }
377 }
378
379 if let Err(error) = apply_exclude_update(exclude_update) {
380 let _ = filesystem::remove_dir_all(&stage);
381 return Err(error);
382 }
383
384 let mut changes = Vec::new();
385 for skill in &skills {
386 let destination = skills_root.join(&skill.name);
387 let backup = backups.join(&skill.name);
388 let had_previous = destination_exists[&skill.name];
389 if had_previous && let Err(error) = filesystem::rename(&destination, &backup) {
390 rollback(&skills_root, &backups, &changes);
391 let _ = filesystem::remove_dir_all(&stage);
392 return Err(Error::new(format!(
393 "cannot move existing skill {}: {error}",
394 destination.display()
395 )));
396 }
397
398 if let Err(error) = filesystem::rename(new_skills.join(&skill.name), &destination) {
399 if had_previous {
400 let _ = filesystem::rename(&backup, &destination);
401 }
402 rollback(&skills_root, &backups, &changes);
403 let _ = filesystem::remove_dir_all(&stage);
404 return Err(Error::new(format!(
405 "cannot install skill {}: {error}",
406 destination.display()
407 )));
408 }
409 changes.push(AppliedChange::Installed {
410 name: skill.name.clone(),
411 had_previous,
412 });
413 }
414
415 for name in &stale_skills {
416 let destination = skills_root.join(name);
417 if stale_exists[name] {
418 if let Err(error) = filesystem::rename(&destination, backups.join(name)) {
419 rollback(&skills_root, &backups, &changes);
420 let _ = filesystem::remove_dir_all(&stage);
421 return Err(Error::new(format!(
422 "cannot remove stale installed skill {}: {error}",
423 destination.display()
424 )));
425 }
426 changes.push(AppliedChange::Removed { name: name.clone() });
427 }
428 }
429
430 let staged_registry = stage.join("registry.json");
431 if let Err(error) = filesystem::write(&staged_registry, encoded_registry) {
432 rollback(&skills_root, &backups, &changes);
433 let _ = filesystem::remove_dir_all(&stage);
434 return Err(Error::new(format!(
435 "cannot write staged skill registry {}: {error}",
436 staged_registry.display()
437 )));
438 }
439
440 if had_registry
441 && let Err(error) = filesystem::rename(®istry_path, backups.join("registry.json"))
442 {
443 rollback(&skills_root, &backups, &changes);
444 let _ = filesystem::remove_dir_all(&stage);
445 return Err(Error::new(format!(
446 "cannot move existing skill registry {}: {error}",
447 registry_path.display()
448 )));
449 }
450 if let Err(error) = filesystem::rename(&staged_registry, ®istry_path) {
451 if had_registry {
452 let _ = filesystem::rename(backups.join("registry.json"), ®istry_path);
453 }
454 rollback(&skills_root, &backups, &changes);
455 let _ = filesystem::remove_dir_all(&stage);
456 return Err(Error::new(format!(
457 "cannot update skill registry {}: {error}",
458 registry_path.display()
459 )));
460 }
461
462 filesystem::remove_dir_all(&stage)
463 .map_err(|error| Error::new(format!("cannot remove {}: {error}", stage.display())))?;
464
465 Ok(skills
466 .iter()
467 .map(|skill| format!("{} ({})", skill.name, skill.package_selector()))
468 .collect())
469}
470
471fn apply_exclude_update(update: Option<super::exclude::Update>) -> Result<()> {
472 if let Some(update) = update {
473 update.apply()?;
474 }
475 Ok(())
476}
477
478fn staging_path(skills_root: &Path) -> PathBuf {
479 let sequence = STAGING_SEQUENCE.fetch_add(1, AtomicOrdering::Relaxed);
480 skills_root.join(format!(
481 ".agent-context-staging-{}-{sequence}",
482 std::process::id()
483 ))
484}
485
486pub(crate) fn frontmatter_description(document: &Node, source: &Path) -> Result<Option<String>> {
487 let Some(frontmatter) = context_frontmatter(document, source)? else {
488 return Ok(None);
489 };
490 let Some(description) = frontmatter.description else {
491 return Ok(None);
492 };
493 let description = description.trim();
494 if description.is_empty() {
495 return Ok(None);
496 }
497 Ok(Some(description.to_owned()))
498}
499
500fn context_frontmatter(document: &Node, source: &Path) -> Result<Option<ContextFrontmatter>> {
501 let children = document
502 .children()
503 .expect("a Markdown document root always has children");
504 let Some(Node::Yaml(frontmatter)) = children.first() else {
505 return Ok(None);
506 };
507
508 serde_yaml_ng::from_str(&frontmatter.value)
509 .map(Some)
510 .map_err(|error| {
511 Error::new(format!(
512 "invalid YAML front matter in {}: {error}",
513 source.display()
514 ))
515 })
516}
517
518fn validate_skill_name(name: &str) -> Result<()> {
519 let valid = !name.is_empty()
520 && name.len() <= 64
521 && !name.starts_with('-')
522 && !name.ends_with('-')
523 && !name.contains("--")
524 && name
525 .bytes()
526 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
527 if valid {
528 Ok(())
529 } else {
530 Err(Error::new(format!(
531 "invalid skill name {name:?}; use 1–64 lowercase ASCII letters, digits, or single hyphens"
532 )))
533 }
534}
535
536fn source_name(source: &Path) -> Result<String> {
537 let Some(stem) = source.file_stem().and_then(|stem| stem.to_str()) else {
538 return Err(Error::new(format!(
539 "invalid skill filename: {}",
540 source.display()
541 )));
542 };
543 Ok(stem.to_owned())
544}
545
546fn write_staged_skill(skill: &Skill, destination: &Path) -> Result<()> {
547 filesystem::create_dir_all(destination)
548 .map_err(|error| Error::new(format!("cannot create {}: {error}", destination.display())))?;
549
550 if let Some(assets) = &skill.assets {
551 copy_skill_assets(assets, destination, true)?;
552 }
553
554 let metadata = SkillFrontmatter {
555 name: &skill.name,
556 description: &skill.description,
557 };
558 let yaml = serde_yaml_ng::to_string(&metadata)
559 .expect("skill front matter contains only serializable strings");
560 let mut output = format!("---\n{yaml}---\n\n");
561 output.push_str(&skill.body);
562 if !output.ends_with('\n') {
563 output.push('\n');
564 }
565
566 let skill_file = destination.join("SKILL.md");
567 filesystem::write(&skill_file, output)
568 .map_err(|error| Error::new(format!("cannot write {}: {error}", skill_file.display())))
569}
570
571fn copy_skill_assets(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
572 let entries = filesystem::read_dir(source)
573 .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?;
574 let mut entries = entries.collect::<std::io::Result<Vec<_>>>()?;
575 entries.sort_by_key(|entry| entry.file_name());
576
577 for entry in entries {
578 let source_path = entry.path();
579 let destination_path = destination.join(entry.file_name());
580 copy_skill_asset(&source_path, &destination_path, top_level)?;
581 }
582
583 Ok(())
584}
585
586fn copy_skill_asset(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
587 let metadata = inspect_skill_asset(source)?;
588 let file_type = metadata.file_type();
589
590 if file_type.is_symlink() {
591 return Err(Error::new(format!(
592 "skill assets cannot contain symbolic links: {}",
593 source.display()
594 )));
595 } else if file_type.is_dir() {
596 filesystem::create_dir(destination).map_err(|error| {
597 Error::new(format!("cannot create {}: {error}", destination.display()))
598 })?;
599 copy_skill_assets(source, destination, false)?;
600 } else if file_type.is_file() {
601 if top_level
602 && source
603 .file_name()
604 .unwrap_or_default()
605 .to_string_lossy()
606 .eq_ignore_ascii_case("SKILL.md")
607 {
608 return Err(Error::new(format!(
609 "{} is reserved for the generated skill instructions",
610 source.display()
611 )));
612 }
613 filesystem::copy(source, destination)
614 .map(|_| ())
615 .map_err(|error| {
616 Error::new(format!(
617 "cannot copy {} to {}: {error}",
618 source.display(),
619 destination.display()
620 ))
621 })?;
622 } else {
623 return Err(Error::new(format!(
624 "unsupported skill asset: {}",
625 source.display()
626 )));
627 }
628 Ok(())
629}
630
631fn inspect_skill_asset(path: &Path) -> Result<filesystem::Metadata> {
632 filesystem::symlink_metadata(path)
633 .map_err(|error| Error::new(format!("cannot inspect {}: {error}", path.display())))
634}
635
636fn ensure_directory(path: &Path) -> Result<()> {
637 match filesystem::symlink_metadata(path) {
638 Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
639 Ok(_) => Err(Error::new(format!(
640 "skill installation path {} is not a regular directory",
641 path.display()
642 ))),
643 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
644 match filesystem::create_dir_all(path) {
645 Ok(()) => Ok(()),
646 Err(error) => Err(Error::new(format!(
647 "cannot create {}: {error}",
648 path.display()
649 ))),
650 }
651 }
652 Err(error) => Err(Error::new(format!(
653 "cannot inspect {}: {error}",
654 path.display()
655 ))),
656 }
657}
658
659pub(crate) fn installed_skill_names(root: &Path) -> Result<Vec<String>> {
660 let registry_path = root.join(".agents/skills").join(REGISTRY_FILE);
661 let registry = load_registry(®istry_path)?;
662 Ok(registry.skills.keys().cloned().collect())
663}
664
665fn load_registry(path: &Path) -> Result<Registry> {
666 load_registry_with_existence(path).map(|(registry, _)| registry)
667}
668
669fn load_registry_with_existence(path: &Path) -> Result<(Registry, bool)> {
670 let metadata = match filesystem::symlink_metadata(path) {
671 Ok(metadata) => metadata,
672 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
673 return Ok((Registry::default(), false));
674 }
675 Err(error) => {
676 return Err(Error::new(format!(
677 "cannot inspect {}: {error}",
678 path.display()
679 )));
680 }
681 };
682 if !metadata.file_type().is_file() {
683 return Err(Error::new(format!(
684 "skill registry {} is not a regular file",
685 path.display()
686 )));
687 }
688
689 let bytes = match filesystem::read(path) {
690 Ok(bytes) => bytes,
691 Err(error) => {
692 return Err(Error::new(format!(
693 "cannot read {}: {error}",
694 path.display()
695 )));
696 }
697 };
698 let registry: Registry = serde_json::from_slice(&bytes).map_err(|error| {
699 Error::new(format!(
700 "invalid skill registry {}: {error}",
701 path.display()
702 ))
703 })?;
704 if registry.version != REGISTRY_VERSION {
705 return Err(Error::new(format!(
706 "unsupported skill registry version {} in {}",
707 registry.version,
708 path.display()
709 )));
710 }
711 for name in registry.skills.keys() {
712 validate_skill_name(name)?;
713 }
714 Ok((registry, true))
715}
716
717fn path_exists(path: &Path) -> Result<bool> {
718 match filesystem::symlink_metadata(path) {
719 Ok(_) => Ok(true),
720 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
721 Err(error) => Err(Error::new(format!(
722 "cannot inspect {}: {error}",
723 path.display()
724 ))),
725 }
726}
727
728enum AppliedChange {
729 Installed { name: String, had_previous: bool },
730 Removed { name: String },
731}
732
733fn rollback(root: &Path, backups: &Path, changes: &[AppliedChange]) {
734 for change in changes.iter().rev() {
735 match change {
736 AppliedChange::Installed { name, had_previous } => {
737 let destination = root.join(name);
738 let _ = remove_existing(&destination);
739 if *had_previous {
740 let _ = filesystem::rename(backups.join(name), destination);
741 }
742 }
743 AppliedChange::Removed { name } => {
744 let _ = filesystem::rename(backups.join(name), root.join(name));
745 }
746 }
747 }
748}
749
750fn remove_existing(path: &Path) -> Result<()> {
751 let metadata = match filesystem::symlink_metadata(path) {
752 Ok(metadata) => metadata,
753 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
754 Err(error) => {
755 return Err(Error::new(format!(
756 "cannot inspect {}: {error}",
757 path.display()
758 )));
759 }
760 };
761 let result = if metadata.file_type().is_dir() {
762 filesystem::remove_dir_all(path)
763 } else {
764 filesystem::remove_file(path)
765 };
766 result.map_err(|error| Error::new(format!("cannot remove {}: {error}", path.display())))
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772 use std::fs;
773 use tempfile::tempdir;
774
775 fn package(root: &Path, name: &str, version: &str) -> ContextPackage {
776 let context_path = root.join(format!("{name}-{version}/context"));
777 fs::create_dir_all(&context_path).unwrap();
778 super::super::installer::ContextPackage::for_test(name, version, context_path)
779 }
780
781 fn make_installer(root: &Path, packages: Vec<ContextPackage>) -> Installer {
782 super::super::installer::Installer::for_test(root, packages)
783 }
784
785 fn write(root: &Path, relative: &str, contents: &str) {
786 let path = root.join(relative);
787 fs::create_dir_all(path.parent().unwrap()).unwrap();
788 fs::write(path, contents).unwrap();
789 }
790
791 fn skill_document(description: &str, body: &str) -> String {
792 format!("---\ntype: skill\ndescription: {description}\n---\n\n{body}")
793 }
794
795 fn write_skill(package: &ContextPackage, file: &str, contents: &str) {
796 write(&package.context_path, file, contents);
797 }
798
799 fn error_for_document(name: &str, file: &str, contents: &str) -> String {
800 let directory = tempdir().unwrap();
801 let package = package(directory.path(), name, "1.0.0");
802 write_skill(&package, file, contents);
803 list_package_skills(&package).err().unwrap().to_string()
804 }
805
806 #[test]
807 fn discovers_skills_in_sorted_order_with_normalized_package_prefixes() {
808 let directory = tempdir().unwrap();
809 let root = directory.path();
810 let later = package(root, "zeta", "1.0.0");
811 let earlier = package(root, "alpha_provider", "2.0.0");
812 write_skill(
813 &later,
814 "second.md",
815 &skill_document(" Second skill. ", "# Second"),
816 );
817 write_skill(
818 &earlier,
819 "first.md",
820 &skill_document("First skill.", "# First"),
821 );
822 write_skill(&earlier, "ordinary.md", "# Ordinary context\n");
823 write_skill(
824 &earlier,
825 "other-type.md",
826 "---\ndescription: not opted in\n---\n\n# Ordinary\n",
827 );
828
829 let installer = make_installer(root, vec![later, earlier]);
830 let skills = list_skills(&installer, None).unwrap();
831 assert_eq!(
832 skills
833 .iter()
834 .map(|skill| skill.name.as_str())
835 .collect::<Vec<_>>(),
836 ["alpha-provider-first", "zeta-second",]
837 );
838 assert_eq!(skills[0].package_selector(), "alpha_provider@2.0.0");
839 assert_eq!(skills[0].description, "First skill.");
840 assert!(list_skills(&installer, Some("missing")).unwrap().is_empty());
841 assert_eq!(
842 list_skills(&installer, Some("zeta@1.0.0")).unwrap().len(),
843 1
844 );
845 }
846
847 #[test]
848 fn sorts_skills_by_provider_name_version_and_skill_name() {
849 let directory = tempdir().unwrap();
850 let root = directory.path();
851 let newer = package(root, "provider", "2.0.0");
852 let older = package(root, "provider", "1.0.0");
853 let alpha = package(root, "alpha", "1.0.0");
854 write_skill(&newer, "first.md", &skill_document("First.", "# First"));
855 write_skill(&older, "zeta.md", &skill_document("Zeta.", "# Zeta"));
856 write_skill(&older, "alpha.md", &skill_document("Alpha.", "# Alpha"));
857 write_skill(&alpha, "one.md", &skill_document("One.", "# One"));
858
859 let skills = list_skills(&make_installer(root, vec![newer, older, alpha]), None).unwrap();
860 let names: Vec<_> = skills.iter().map(|skill| skill.name.as_str()).collect();
861 assert_eq!(
862 names,
863 [
864 "alpha-one",
865 "provider-alpha",
866 "provider-zeta",
867 "provider-first"
868 ]
869 );
870 }
871
872 #[test]
873 fn rejects_unsupported_nested_invalid_and_incomplete_skill_documents() {
874 assert!(
875 error_for_document(
876 "provider",
877 "unsupported.md",
878 "---\ntype: guide\ndescription: Guide.\n---\n\n# Guide\n"
879 )
880 .contains("unsupported context type")
881 );
882 assert!(
883 error_for_document(
884 "provider",
885 "nested/skill.md",
886 &skill_document("Nested skill.", "# Skill\n")
887 )
888 .contains("directly inside context")
889 );
890 assert!(
891 error_for_document(
892 "provider",
893 "Bad_Name.md",
894 &skill_document("Invalid name.", "# Skill\n")
895 )
896 .contains("invalid skill name")
897 );
898 assert!(
899 error_for_document(
900 &"p".repeat(61),
901 "name.md",
902 &skill_document("Long prefix.", "# Skill\n")
903 )
904 .contains("invalid skill name")
905 );
906 assert!(
907 error_for_document(
908 "provider",
909 "missing-description.md",
910 "---\ntype: skill\n---\n\n# Skill\n"
911 )
912 .contains("requires a non-empty")
913 );
914 assert!(
915 error_for_document(
916 "provider",
917 "blank-description.md",
918 "---\ntype: skill\ndescription: ' '\n---\n\n# Skill\n"
919 )
920 .contains("requires a non-empty")
921 );
922 assert!(
923 error_for_document(
924 "provider",
925 "long-description.md",
926 &skill_document(&"x".repeat(1025), "# Skill\n")
927 )
928 .contains("1024 character limit")
929 );
930 assert!(
931 error_for_document(
932 "provider",
933 "unknown-key.md",
934 "---\ntype: skill\ndescription: Skill.\nunknown: value\n---\n\n# Skill\n"
935 )
936 .contains("invalid YAML front matter")
937 );
938 }
939
940 #[test]
941 fn propagates_skill_discovery_errors_through_public_operations() {
942 let directory = tempdir().unwrap();
943 let root = directory.path();
944 let provider = package(root, "provider", "1.0.0");
945 write_skill(
946 &provider,
947 "unsupported.md",
948 "---\ntype: guide\ndescription: Guide.\n---\n\n# Guide\n",
949 );
950 let installer = make_installer(root, vec![provider]);
951
952 assert!(
953 list_skills(&installer, None)
954 .unwrap_err()
955 .to_string()
956 .contains("unsupported context type")
957 );
958 assert!(
959 install_skills(&installer, None, None)
960 .unwrap_err()
961 .to_string()
962 .contains("unsupported context type")
963 );
964 assert!(
965 install_skills(&installer, Some("provider@1.0.0"), None)
966 .unwrap_err()
967 .to_string()
968 .contains("unsupported context type")
969 );
970 }
971
972 #[test]
973 fn reports_context_directory_read_errors_during_skill_discovery() {
974 let directory = tempdir().unwrap();
975 let root = directory.path();
976 let provider = package(root, "provider", "1.0.0");
977 fs::remove_dir(&provider.context_path).unwrap();
978 fs::write(&provider.context_path, "not a directory").unwrap();
979
980 assert!(
981 list_package_skills(&provider)
982 .unwrap_err()
983 .to_string()
984 .contains("cannot read")
985 );
986 }
987
988 #[cfg(unix)]
989 #[test]
990 fn parses_invalid_unicode_filenames_without_creating_them_on_disk() {
991 use std::os::unix::ffi::OsStrExt;
992
993 let directory = tempdir().unwrap();
994 let provider = package(directory.path(), "provider", "1.0.0");
995 let source = provider
996 .context_path
997 .join(std::ffi::OsStr::from_bytes(b"invalid\xff.md"));
998 let error = parse_skill_document(
999 &provider,
1000 &source,
1001 &skill_document("Invalid filename.", "# Skill\n"),
1002 )
1003 .unwrap_err()
1004 .to_string();
1005 assert!(error.contains("invalid skill filename"));
1006 }
1007
1008 #[test]
1009 fn validates_skill_assets_and_frontmatter_structure() {
1010 let directory = tempdir().unwrap();
1011 let package = package(directory.path(), "provider", "1.0.0");
1012 write_skill(&package, "asset.md", &skill_document("Asset.", "# Asset\n"));
1013 fs::write(package.context_path.join("asset"), "not a directory").unwrap();
1014 assert!(
1015 list_package_skills(&package)
1016 .err()
1017 .unwrap()
1018 .to_string()
1019 .contains("is not a directory")
1020 );
1021
1022 let blocker = directory.path().join("asset-parent-is-file");
1023 fs::write(&blocker, "file").unwrap();
1024 let failure_path = blocker.join("skill");
1025 let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| {
1026 path == failure_path
1027 });
1028 assert!(
1029 skill_assets_path(&blocker, "skill")
1030 .err()
1031 .unwrap()
1032 .to_string()
1033 .contains("cannot inspect skill assets")
1034 );
1035
1036 let no_frontmatter = to_mdast("# Heading\n", &ParseOptions::default()).unwrap();
1037 assert!(
1038 context_frontmatter(&no_frontmatter, Path::new("plain.md"))
1039 .unwrap()
1040 .is_none()
1041 );
1042 assert!(
1043 frontmatter_description(&no_frontmatter, Path::new("plain.md"))
1044 .unwrap()
1045 .is_none()
1046 );
1047 assert!(
1048 parse_skill_document(&package, Path::new("plain.md"), "# Plain\n")
1049 .unwrap()
1050 .is_none()
1051 );
1052
1053 assert!(validate_skill_name("valid-name-12").is_ok());
1054 for invalid in [
1055 "",
1056 "-first",
1057 "last-",
1058 "double--dash",
1059 "Upper",
1060 "white space",
1061 &"a".repeat(65),
1062 ] {
1063 assert!(validate_skill_name(invalid).is_err(), "{invalid:?}");
1064 }
1065 }
1066
1067 #[test]
1068 fn normalizes_frontmatter_descriptions_and_omits_blank_descriptions() {
1069 let mut options = ParseOptions::default();
1070 options.constructs.frontmatter = true;
1071 let document = to_mdast(
1072 "---\ndescription: \" A useful guide. \"\n---\n\n# Guide\n",
1073 &options,
1074 )
1075 .unwrap();
1076 assert_eq!(
1077 frontmatter_description(&document, Path::new("guide.md")).unwrap(),
1078 Some("A useful guide.".to_owned())
1079 );
1080
1081 let document = to_mdast("---\ndescription: \" \"\n---\n\n# Guide\n", &options).unwrap();
1082 assert_eq!(
1083 frontmatter_description(&document, Path::new("guide.md")).unwrap(),
1084 None
1085 );
1086 }
1087
1088 #[cfg(unix)]
1089 #[test]
1090 fn reports_invalid_filenames_and_skill_asset_inspection_errors() {
1091 use std::os::unix::ffi::OsStrExt;
1092
1093 assert_eq!(
1094 source_name(Path::new("valid-name.md")).unwrap(),
1095 "valid-name"
1096 );
1097 let invalid_filename = Path::new(std::ffi::OsStr::from_bytes(b"invalid\xff.md"));
1098 assert!(
1099 source_name(invalid_filename)
1100 .unwrap_err()
1101 .to_string()
1102 .contains("invalid skill filename")
1103 );
1104
1105 let directory = tempdir().unwrap();
1106 let error = inspect_skill_asset(&directory.path().join("missing/assets"))
1107 .unwrap_err()
1108 .to_string();
1109 assert!(error.contains("cannot inspect"));
1110 }
1111
1112 #[cfg(unix)]
1113 #[test]
1114 fn reports_skill_removal_errors() {
1115 use std::os::unix::fs::PermissionsExt;
1116
1117 let directory = tempdir().unwrap();
1118 let parent = directory.path().join("skills");
1119 fs::create_dir(&parent).unwrap();
1120 let skill = parent.join("installed");
1121 fs::write(&skill, "previous skill").unwrap();
1122 fs::set_permissions(&parent, fs::Permissions::from_mode(0o555)).unwrap();
1123
1124 let result = remove_existing(&skill);
1125 fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
1126
1127 assert!(result.unwrap_err().to_string().contains("cannot remove"));
1128 }
1129
1130 #[test]
1131 fn staged_skill_includes_assets_frontmatter_and_final_newline() {
1132 let directory = tempdir().unwrap();
1133 let root = directory.path();
1134 let assets = root.join("assets");
1135 fs::create_dir_all(assets.join("references")).unwrap();
1136 fs::write(assets.join("references/guide.md"), "Guide.\n").unwrap();
1137 fs::write(assets.join("alpha.txt"), "Alpha asset.\n").unwrap();
1138 fs::write(assets.join("zeta.txt"), "Zeta asset.\n").unwrap();
1139 let package = package(root, "provider", "1.0.0");
1140 let skill = Skill {
1141 name: "provider-example".to_owned(),
1142 description: "Example skill.".to_owned(),
1143 package,
1144 source_name: "example".to_owned(),
1145 assets: Some(assets),
1146 body: "# Example".to_owned(),
1147 };
1148 let destination = root.join("installed/provider-example");
1149 write_staged_skill(&skill, &destination).unwrap();
1150 let markdown = fs::read_to_string(destination.join("SKILL.md")).unwrap();
1151 assert!(
1152 markdown
1153 .starts_with("---\nname: provider-example\ndescription: Example skill.\n---\n\n")
1154 );
1155 assert!(markdown.ends_with("# Example\n"));
1156 assert_eq!(
1157 fs::read_to_string(destination.join("references/guide.md")).unwrap(),
1158 "Guide.\n"
1159 );
1160 assert_eq!(
1161 fs::read_to_string(destination.join("alpha.txt")).unwrap(),
1162 "Alpha asset.\n"
1163 );
1164 assert_eq!(
1165 fs::read_to_string(destination.join("zeta.txt")).unwrap(),
1166 "Zeta asset.\n"
1167 );
1168
1169 let invalid_assets = root.join("invalid-assets");
1170 fs::write(&invalid_assets, "not a directory").unwrap();
1171 let invalid_skill = Skill {
1172 assets: Some(invalid_assets),
1173 ..skill.clone()
1174 };
1175 assert!(
1176 write_staged_skill(&invalid_skill, &root.join("invalid-install"))
1177 .unwrap_err()
1178 .to_string()
1179 .contains("cannot read")
1180 );
1181
1182 let bad_destination = root.join("blocked");
1183 fs::write(&bad_destination, "file").unwrap();
1184 assert!(write_staged_skill(&skill, &bad_destination).is_err());
1185
1186 let blocked_skill = root.join("blocked-skill");
1187 fs::create_dir_all(blocked_skill.join("SKILL.md")).unwrap();
1188 assert!(
1189 write_staged_skill(&skill, &blocked_skill)
1190 .unwrap_err()
1191 .to_string()
1192 .contains("cannot write")
1193 );
1194 }
1195
1196 #[test]
1197 fn reports_skill_asset_directory_entry_errors() {
1198 let directory = tempdir().unwrap();
1199 let assets = directory.path().join("assets");
1200 let destination = directory.path().join("destination");
1201 fs::create_dir(&assets).unwrap();
1202 fs::create_dir(&destination).unwrap();
1203
1204 let failure_path = assets.clone();
1205 let _failure =
1206 filesystem::fail_once(filesystem::Operation::ReadDirectoryEntry, move |path| {
1207 path == failure_path
1208 });
1209
1210 let error = copy_skill_assets(&assets, &destination, true).unwrap_err();
1211
1212 assert!(error.to_string().contains("injected filesystem failure"));
1213 }
1214
1215 #[cfg(unix)]
1216 #[test]
1217 fn reports_context_document_read_errors() {
1218 use std::os::unix::fs::PermissionsExt;
1219
1220 let directory = tempdir().unwrap();
1221 let package = package(directory.path(), "provider", "1.0.0");
1222 let document = package.context_path.join("unreadable.md");
1223 fs::write(&document, "---\ntype: skill\ndescription: Skill.\n---\n").unwrap();
1224 let mut permissions = fs::metadata(&document).unwrap().permissions();
1225 permissions.set_mode(0o0);
1226 fs::set_permissions(&document, permissions).unwrap();
1227
1228 let error = list_package_skills(&package).unwrap_err();
1229 assert!(error.to_string().contains("cannot read"));
1230
1231 let mut permissions = fs::metadata(&document).unwrap().permissions();
1232 permissions.set_mode(0o600);
1233 fs::set_permissions(&document, permissions).unwrap();
1234 }
1235
1236 #[cfg(unix)]
1237 #[test]
1238 fn rejects_symlinks_reserved_files_and_unsupported_skill_assets() {
1239 use std::os::unix::fs::symlink;
1240 use std::os::unix::net::UnixListener;
1241
1242 let directory = tempdir().unwrap();
1243 let root = directory.path();
1244 let assets = root.join("assets");
1245 let destination = root.join("destination");
1246 fs::create_dir_all(&assets).unwrap();
1247 fs::create_dir_all(&destination).unwrap();
1248 let target = root.join("target");
1249 fs::write(&target, "target").unwrap();
1250 symlink(&target, assets.join("link")).unwrap();
1251 assert!(
1252 copy_skill_assets(&assets, &destination, true)
1253 .err()
1254 .unwrap()
1255 .to_string()
1256 .contains("symbolic links")
1257 );
1258
1259 fs::remove_file(assets.join("link")).unwrap();
1260 fs::write(assets.join("skill.MD"), "reserved").unwrap();
1261 assert!(
1262 copy_skill_assets(&assets, &destination, true)
1263 .err()
1264 .unwrap()
1265 .to_string()
1266 .contains("reserved")
1267 );
1268
1269 fs::remove_file(assets.join("skill.MD")).unwrap();
1270 let socket_path = assets.join("socket");
1271 let _listener = UnixListener::bind(&socket_path).unwrap();
1272 assert!(
1273 copy_skill_assets(&assets, &destination, true)
1274 .err()
1275 .unwrap()
1276 .to_string()
1277 .contains("unsupported skill asset")
1278 );
1279 drop(_listener);
1280 fs::remove_file(socket_path).unwrap();
1281
1282 let nested = assets.join("nested");
1283 fs::create_dir(&nested).unwrap();
1284 let nested_socket_path = nested.join("socket");
1285 let nested_listener = UnixListener::bind(&nested_socket_path).unwrap();
1286 assert!(
1287 copy_skill_assets(&assets, &destination, true)
1288 .unwrap_err()
1289 .to_string()
1290 .contains("unsupported skill asset")
1291 );
1292 drop(nested_listener);
1293 fs::remove_file(nested_socket_path).unwrap();
1294 fs::remove_dir(nested).unwrap();
1295
1296 let copy_source = root.join("copy-source");
1297 let copy_destination = root.join("copy-destination");
1298 fs::create_dir_all(©_source).unwrap();
1299 fs::create_dir_all(copy_destination.join("note.txt")).unwrap();
1300 fs::write(copy_source.join("note.txt"), "note").unwrap();
1301 assert!(
1302 copy_skill_assets(©_source, ©_destination, true)
1303 .err()
1304 .unwrap()
1305 .to_string()
1306 .contains("cannot copy")
1307 );
1308
1309 let missing = root.join("missing");
1310 assert!(copy_skill_assets(&missing, &destination, true).is_err());
1311 assert!(
1312 copy_skill_asset(&missing, &destination.join("missing"), true)
1313 .unwrap_err()
1314 .to_string()
1315 .contains("cannot inspect")
1316 );
1317 fs::create_dir(assets.join("folder")).unwrap();
1318 fs::create_dir(destination.join("folder")).unwrap();
1319 assert!(copy_skill_assets(&assets, &destination, true).is_err());
1320 }
1321
1322 #[test]
1323 fn validates_skill_registry_and_installation_directory_states() {
1324 let directory = tempdir().unwrap();
1325 let root = directory.path();
1326 let registry_path = root.join("registry.json");
1327 assert_eq!(
1328 load_registry(®istry_path).unwrap().version,
1329 REGISTRY_VERSION
1330 );
1331 assert!(
1332 !load_registry_with_existence(&root.join("absent.json"))
1333 .unwrap()
1334 .1
1335 );
1336 assert!(installed_skill_names(root).unwrap().is_empty());
1337
1338 fs::create_dir(®istry_path).unwrap();
1339 assert!(
1340 load_registry(®istry_path)
1341 .err()
1342 .unwrap()
1343 .to_string()
1344 .contains("not a regular file")
1345 );
1346 fs::remove_dir(®istry_path).unwrap();
1347 fs::write(®istry_path, "not json").unwrap();
1348 assert!(
1349 load_registry(®istry_path)
1350 .err()
1351 .unwrap()
1352 .to_string()
1353 .contains("invalid skill registry")
1354 );
1355 fs::write(®istry_path, r#"{"version": 2, "skills": {}}"#).unwrap();
1356 assert!(
1357 load_registry(®istry_path)
1358 .err()
1359 .unwrap()
1360 .to_string()
1361 .contains("unsupported skill registry version")
1362 );
1363 fs::write(®istry_path, r#"{"version": 1, "skills": {"Bad_Name": {"package": "provider", "version": "1.0.0"}}}"#).unwrap();
1364 assert!(
1365 load_registry(®istry_path)
1366 .err()
1367 .unwrap()
1368 .to_string()
1369 .contains("invalid skill name")
1370 );
1371
1372 let blocker = root.join("registry-parent-is-file");
1373 fs::write(&blocker, "file").unwrap();
1374 let blocked_registry_path = blocker.join("registry.json");
1375 let failure_path = blocked_registry_path.clone();
1376 let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| {
1377 path == failure_path
1378 });
1379 assert!(
1380 load_registry(&blocked_registry_path)
1381 .err()
1382 .unwrap()
1383 .to_string()
1384 .contains("cannot inspect")
1385 );
1386
1387 assert!(path_exists(®istry_path).unwrap());
1388 assert!(!path_exists(&root.join("absent")).unwrap());
1389 let blocker = root.join("blocker");
1390 fs::write(&blocker, "file").unwrap();
1391 let child = blocker.join("child");
1392 let failure_path = child.clone();
1393 let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| {
1394 path == failure_path
1395 });
1396 assert!(path_exists(&child).is_err());
1397 let failure_path = child.clone();
1398 let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| {
1399 path == failure_path
1400 });
1401 assert!(remove_existing(&child).is_err());
1402 assert!(ensure_directory(&blocker).is_err());
1403 assert!(ensure_directory(&blocker.join("child")).is_err());
1404 let new_directory = root.join("new-directory");
1405 ensure_directory(&new_directory).unwrap();
1406 ensure_directory(&new_directory).unwrap();
1407
1408 let removable_file = root.join("removable-file");
1409 fs::write(&removable_file, "file").unwrap();
1410 remove_existing(&removable_file).unwrap();
1411 assert!(!removable_file.exists());
1412 }
1413
1414 #[test]
1415 fn reconciles_owned_skills_and_rejects_missing_or_ambiguous_selections() {
1416 let directory = tempdir().unwrap();
1417 let root = directory.path();
1418 let first = package(root, "provider", "1.0.0");
1419 let second = package(root, "provider", "2.0.0");
1420 write_skill(&first, "one.md", &skill_document("One.", "# One\n"));
1421 write_skill(&second, "one.md", &skill_document("One v2.", "# One\n"));
1422 let installer = make_installer(root, vec![first.clone(), second.clone()]);
1423
1424 assert!(list_skills(&installer, Some("provider")).is_err());
1425 assert!(install_skills(&installer, Some("provider"), None).is_err());
1426
1427 let error = install_skills(&installer, None, Some("provider-one"))
1428 .err()
1429 .unwrap();
1430 assert!(error.to_string().contains("provided by multiple crates"));
1431 let error = install_skills(&installer, None, None).err().unwrap();
1432 assert!(
1433 error
1434 .to_string()
1435 .contains("multiple selected crates provide skill")
1436 );
1437 assert!(install_skills(&installer, None, Some("unknown")).is_err());
1438 assert!(install_skills(&installer, Some("missing"), None).is_err());
1439
1440 let directory = tempdir().unwrap();
1441 let root = directory.path();
1442 let provider = package(root, "provider", "1.0.0");
1443 write_skill(&provider, "one.md", &skill_document("One.", "# One\n"));
1444 let installer = make_installer(root, vec![provider.clone()]);
1445 let skills_root = root.join(".agents/skills");
1446 fs::create_dir_all(&skills_root).unwrap();
1447 let registry = Registry {
1448 version: REGISTRY_VERSION,
1449 skills: BTreeMap::from([(
1450 "provider-one".to_owned(),
1451 SkillOwner {
1452 package: "different-provider".to_owned(),
1453 version: "1.0.0".to_owned(),
1454 },
1455 )]),
1456 };
1457 fs::write(
1458 skills_root.join(REGISTRY_FILE),
1459 serde_json::to_vec(®istry).unwrap(),
1460 )
1461 .unwrap();
1462 assert!(
1463 install_skills(&installer, None, None)
1464 .err()
1465 .unwrap()
1466 .to_string()
1467 .contains("already installed from crate")
1468 );
1469
1470 let directory = tempdir().unwrap();
1471 let root = directory.path();
1472 let empty = make_installer(root, Vec::new());
1473 assert!(install_skills(&empty, None, None).unwrap().is_empty());
1474 assert!(install_skills(&empty, None, None).unwrap().is_empty());
1475 assert!(root.join(".agents/skills").join(REGISTRY_FILE).is_file());
1476 }
1477
1478 #[test]
1479 fn reconciliation_removes_stale_skills_and_preserves_other_packages() {
1480 let directory = tempdir().unwrap();
1481 let root = directory.path();
1482 let provider = package(root, "provider", "1.0.0");
1483 let other = package(root, "other", "1.0.0");
1484 let installer = make_installer(root, vec![provider.clone(), other]);
1485 let skills_root = root.join(".agents/skills");
1486 fs::create_dir_all(skills_root.join("provider-stale")).unwrap();
1487 fs::create_dir_all(skills_root.join("other-stale")).unwrap();
1488 let registry = Registry {
1489 version: REGISTRY_VERSION,
1490 skills: BTreeMap::from([
1491 (
1492 "provider-stale".to_owned(),
1493 SkillOwner {
1494 package: "provider".to_owned(),
1495 version: "0.9.0".to_owned(),
1496 },
1497 ),
1498 (
1499 "other-stale".to_owned(),
1500 SkillOwner {
1501 package: "other".to_owned(),
1502 version: "0.9.0".to_owned(),
1503 },
1504 ),
1505 (
1506 "provider-vanished".to_owned(),
1507 SkillOwner {
1508 package: "provider".to_owned(),
1509 version: "0.8.0".to_owned(),
1510 },
1511 ),
1512 ]),
1513 };
1514 fs::write(
1515 skills_root.join(REGISTRY_FILE),
1516 serde_json::to_vec(®istry).unwrap(),
1517 )
1518 .unwrap();
1519
1520 assert!(
1521 install_skills(&installer, Some("provider@1.0.0"), None)
1522 .unwrap()
1523 .is_empty()
1524 );
1525 assert!(!skills_root.join("provider-stale").exists());
1526 assert!(skills_root.join("other-stale").is_dir());
1527 let updated = load_registry(&skills_root.join(REGISTRY_FILE)).unwrap();
1528 assert!(!updated.skills.contains_key("provider-stale"));
1529 assert!(!updated.skills.contains_key("provider-vanished"));
1530 assert!(updated.skills.contains_key("other-stale"));
1531 }
1532
1533 #[test]
1534 fn installing_one_skill_preserves_stale_skills_from_other_installations() {
1535 let directory = tempdir().unwrap();
1536 let root = directory.path();
1537 let provider = package(root, "provider", "1.0.0");
1538 write_skill(&provider, "one.md", &skill_document("One.", "# One\n"));
1539 let installer = make_installer(root, vec![provider]);
1540 let skills_root = root.join(".agents/skills");
1541 let stale_skill = skills_root.join("provider-stale");
1542 fs::create_dir_all(&stale_skill).unwrap();
1543 fs::write(stale_skill.join("SKILL.md"), "stale skill\n").unwrap();
1544 let registry = Registry {
1545 version: REGISTRY_VERSION,
1546 skills: BTreeMap::from([(
1547 "provider-stale".to_owned(),
1548 SkillOwner {
1549 package: "provider".to_owned(),
1550 version: "0.9.0".to_owned(),
1551 },
1552 )]),
1553 };
1554 fs::write(
1555 skills_root.join(REGISTRY_FILE),
1556 serde_json::to_vec(®istry).unwrap(),
1557 )
1558 .unwrap();
1559
1560 install_skills(&installer, None, Some("provider-one")).unwrap();
1561
1562 assert!(stale_skill.join("SKILL.md").is_file());
1563 let registry = load_registry(&skills_root.join(REGISTRY_FILE)).unwrap();
1564 assert!(registry.skills.contains_key("provider-stale"));
1565 assert!(registry.skills.contains_key("provider-one"));
1566 }
1567
1568 #[test]
1569 fn does_not_replace_a_project_owned_skill_directory() {
1570 let directory = tempdir().unwrap();
1571 let root = directory.path();
1572 let provider = package(root, "provider", "1.0.0");
1573 write_skill(&provider, "one.md", &skill_document("One.", "# One\n"));
1574 let installer = make_installer(root, vec![provider]);
1575 let destination = root.join(".agents/skills/provider-one");
1576 fs::create_dir_all(&destination).unwrap();
1577 fs::write(destination.join("SKILL.md"), "project-owned skill\n").unwrap();
1578
1579 let error = install_skills(&installer, None, None).unwrap_err();
1580
1581 assert!(
1582 error
1583 .to_string()
1584 .contains("not managed by Bake Agent Context")
1585 );
1586 assert_eq!(
1587 fs::read_to_string(destination.join("SKILL.md")).unwrap(),
1588 "project-owned skill\n"
1589 );
1590 assert!(!root.join(".agents/skills").join(REGISTRY_FILE).exists());
1591 }
1592
1593 #[test]
1594 fn rolls_back_installed_and_removed_skill_changes() {
1595 let directory = tempdir().unwrap();
1596 let root = directory.path().join("skills");
1597 let backups = directory.path().join("backups");
1598 fs::create_dir_all(&root).unwrap();
1599 fs::create_dir_all(&backups).unwrap();
1600 fs::create_dir(root.join("new")).unwrap();
1601 fs::write(root.join("new/SKILL.md"), "new").unwrap();
1602 fs::create_dir_all(backups.join("replaced")).unwrap();
1603 fs::write(backups.join("replaced/SKILL.md"), "old").unwrap();
1604 fs::create_dir_all(backups.join("removed")).unwrap();
1605 fs::write(backups.join("removed/SKILL.md"), "removed").unwrap();
1606
1607 rollback(
1608 &root,
1609 &backups,
1610 &[
1611 AppliedChange::Installed {
1612 name: "new".to_owned(),
1613 had_previous: false,
1614 },
1615 AppliedChange::Installed {
1616 name: "replaced".to_owned(),
1617 had_previous: true,
1618 },
1619 AppliedChange::Removed {
1620 name: "removed".to_owned(),
1621 },
1622 ],
1623 );
1624 assert!(!root.join("new").exists());
1625 assert_eq!(
1626 fs::read_to_string(root.join("replaced/SKILL.md")).unwrap(),
1627 "old"
1628 );
1629 assert_eq!(
1630 fs::read_to_string(root.join("removed/SKILL.md")).unwrap(),
1631 "removed"
1632 );
1633 }
1634
1635 fn transaction_fixture() -> (
1636 tempfile::TempDir,
1637 Installer,
1638 PathBuf,
1639 PathBuf,
1640 PathBuf,
1641 Vec<u8>,
1642 ) {
1643 let directory = tempdir().unwrap();
1644 let root = directory.path();
1645 let provider = package(root, "provider", "1.0.0");
1646 write_skill(
1647 &provider,
1648 "example.md",
1649 &skill_document("Updated example.", "# Updated example"),
1650 );
1651 let installer = make_installer(root, vec![provider]);
1652
1653 let skills_root = root.join(".agents/skills");
1654 let previous_skill = skills_root.join("provider-example");
1655 let stale_skill = skills_root.join("provider-stale");
1656 fs::create_dir_all(&previous_skill).unwrap();
1657 fs::write(previous_skill.join("SKILL.md"), "previous version\n").unwrap();
1658 fs::create_dir_all(&stale_skill).unwrap();
1659 fs::write(stale_skill.join("SKILL.md"), "stale skill\n").unwrap();
1660
1661 let registry_path = skills_root.join(REGISTRY_FILE);
1662 let registry = Registry {
1663 version: REGISTRY_VERSION,
1664 skills: BTreeMap::from([
1665 (
1666 "provider-example".to_owned(),
1667 SkillOwner {
1668 package: "provider".to_owned(),
1669 version: "0.9.0".to_owned(),
1670 },
1671 ),
1672 (
1673 "provider-stale".to_owned(),
1674 SkillOwner {
1675 package: "provider".to_owned(),
1676 version: "0.9.0".to_owned(),
1677 },
1678 ),
1679 ]),
1680 };
1681 let previous_registry = serde_json::to_vec_pretty(®istry).unwrap();
1682 fs::write(®istry_path, &previous_registry).unwrap();
1683
1684 (
1685 directory,
1686 installer,
1687 previous_skill,
1688 stale_skill,
1689 registry_path,
1690 previous_registry,
1691 )
1692 }
1693
1694 fn fresh_install_fixture() -> (tempfile::TempDir, Installer, PathBuf, PathBuf) {
1695 let directory = tempdir().unwrap();
1696 let root = directory.path();
1697 let provider = package(root, "provider", "1.0.0");
1698 write_skill(
1699 &provider,
1700 "example.md",
1701 &skill_document("Example.", "# Example"),
1702 );
1703 let installer = make_installer(root, vec![provider]);
1704 let skills_root = root.join(".agents/skills");
1705 let skill_path = skills_root.join("provider-example");
1706 let registry_path = skills_root.join(REGISTRY_FILE);
1707
1708 (directory, installer, skill_path, registry_path)
1709 }
1710
1711 fn assert_transaction_restored(
1712 previous_skill: &Path,
1713 stale_skill: &Path,
1714 registry_path: &Path,
1715 previous_registry: &[u8],
1716 ) {
1717 assert_eq!(
1718 fs::read_to_string(previous_skill.join("SKILL.md")).unwrap(),
1719 "previous version\n"
1720 );
1721 assert_eq!(
1722 fs::read_to_string(stale_skill.join("SKILL.md")).unwrap(),
1723 "stale skill\n"
1724 );
1725 assert_eq!(fs::read(registry_path).unwrap(), previous_registry);
1726 }
1727
1728 #[test]
1729 fn restores_the_previous_install_when_registry_update_fails() {
1730 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1731 transaction_fixture();
1732 let failure_path = registry_path.clone();
1733 let _failure =
1734 filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| {
1735 path == failure_path
1736 });
1737 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1738
1739 assert!(error.to_string().contains("cannot update skill registry"));
1740 assert_transaction_restored(
1741 &previous_skill,
1742 &stale_skill,
1743 ®istry_path,
1744 &previous_registry,
1745 );
1746 }
1747
1748 #[test]
1749 fn restores_the_previous_skill_when_replacement_fails() {
1750 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1751 transaction_fixture();
1752 let failure_path = previous_skill.clone();
1753 let _failure =
1754 filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| {
1755 path == failure_path
1756 });
1757 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1758
1759 assert!(error.to_string().contains("cannot install skill"));
1760 assert_transaction_restored(
1761 &previous_skill,
1762 &stale_skill,
1763 ®istry_path,
1764 &previous_registry,
1765 );
1766 }
1767
1768 #[test]
1769 fn rolls_back_a_first_install_when_skill_rename_fails() {
1770 let (_directory, installer, skill_path, registry_path) = fresh_install_fixture();
1771 let failure_path = skill_path.clone();
1772 let _failure =
1773 filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| {
1774 path == failure_path
1775 });
1776
1777 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1778
1779 assert!(error.to_string().contains("cannot install skill"));
1780 assert!(!skill_path.exists());
1781 assert!(!registry_path.exists());
1782 }
1783
1784 #[test]
1785 fn rolls_back_a_first_install_when_registry_commit_fails() {
1786 let (_directory, installer, skill_path, registry_path) = fresh_install_fixture();
1787 let failure_path = registry_path.clone();
1788 let _failure =
1789 filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| {
1790 path == failure_path
1791 });
1792
1793 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1794
1795 assert!(error.to_string().contains("cannot update skill registry"));
1796 assert!(!skill_path.exists());
1797 assert!(!registry_path.exists());
1798 }
1799
1800 #[test]
1801 fn preserves_the_previous_install_when_existing_skill_cannot_be_moved() {
1802 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1803 transaction_fixture();
1804 let failure_path = previous_skill.clone();
1805 let _failure = filesystem::fail_once(filesystem::Operation::RenameSource, move |path| {
1806 path == failure_path
1807 });
1808 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1809
1810 assert!(error.to_string().contains("cannot move existing skill"));
1811 assert_transaction_restored(
1812 &previous_skill,
1813 &stale_skill,
1814 ®istry_path,
1815 &previous_registry,
1816 );
1817 }
1818
1819 #[test]
1820 fn rolls_back_installed_skills_when_stale_skill_cannot_be_moved() {
1821 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1822 transaction_fixture();
1823 let failure_path = stale_skill.clone();
1824 let _failure = filesystem::fail_once(filesystem::Operation::RenameSource, move |path| {
1825 path == failure_path
1826 });
1827 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1828
1829 assert!(
1830 error
1831 .to_string()
1832 .contains("cannot remove stale installed skill")
1833 );
1834 assert_transaction_restored(
1835 &previous_skill,
1836 &stale_skill,
1837 ®istry_path,
1838 &previous_registry,
1839 );
1840 }
1841
1842 #[test]
1843 fn rolls_back_installed_skills_when_staged_registry_write_fails() {
1844 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1845 transaction_fixture();
1846 let _failure = filesystem::fail_once(filesystem::Operation::Write, |path| {
1847 path.file_name() == Some(std::ffi::OsStr::new("registry.json"))
1848 });
1849 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1850
1851 assert!(
1852 error
1853 .to_string()
1854 .contains("cannot write staged skill registry")
1855 );
1856 assert_transaction_restored(
1857 &previous_skill,
1858 &stale_skill,
1859 ®istry_path,
1860 &previous_registry,
1861 );
1862 }
1863
1864 #[test]
1865 fn rolls_back_installed_skills_when_existing_registry_cannot_be_moved() {
1866 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1867 transaction_fixture();
1868 let failure_path = registry_path.clone();
1869 let _failure = filesystem::fail_once(filesystem::Operation::RenameSource, move |path| {
1870 path == failure_path
1871 });
1872 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1873
1874 assert!(
1875 error
1876 .to_string()
1877 .contains("cannot move existing skill registry")
1878 );
1879 assert_transaction_restored(
1880 &previous_skill,
1881 &stale_skill,
1882 ®istry_path,
1883 &previous_registry,
1884 );
1885 }
1886
1887 #[test]
1888 fn cleans_staging_directory_when_preparing_staging_files_fails() {
1889 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1890 transaction_fixture();
1891 let _failure = filesystem::fail_once(filesystem::Operation::CreateDirectory, |path| {
1892 path.file_name() == Some(std::ffi::OsStr::new("backups"))
1893 });
1894 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1895
1896 assert!(error.to_string().contains("cannot prepare"));
1897 assert_transaction_restored(
1898 &previous_skill,
1899 &stale_skill,
1900 ®istry_path,
1901 &previous_registry,
1902 );
1903 assert!(
1904 fs::read_dir(registry_path.parent().unwrap())
1905 .unwrap()
1906 .all(|entry| !entry
1907 .unwrap()
1908 .file_name()
1909 .to_string_lossy()
1910 .starts_with(".agent-context-staging-"))
1911 );
1912 }
1913
1914 #[test]
1915 fn reports_staging_directory_creation_failure() {
1916 let (_directory, installer, _, _, _, _) = transaction_fixture();
1917 let _failure = filesystem::fail_once(filesystem::Operation::CreateDirectory, |path| {
1918 path.file_name()
1919 .and_then(std::ffi::OsStr::to_str)
1920 .is_some_and(|name| name.starts_with(".agent-context-staging-"))
1921 });
1922 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1923
1924 assert!(error.to_string().contains("cannot create"));
1925 }
1926
1927 #[test]
1928 fn cleans_staging_files_when_writing_a_skill_fails() {
1929 let (_directory, installer, skill_path, registry_path) = fresh_install_fixture();
1930 let skills_root = registry_path.parent().unwrap();
1931 fs::create_dir_all(skills_root).unwrap();
1932 let unrelated_file = skills_root.join("unrelated-file");
1933 fs::write(&unrelated_file, "keep this file\n").unwrap();
1934 let _failure = filesystem::fail_once(filesystem::Operation::Write, |path| {
1935 path.file_name() == Some(std::ffi::OsStr::new("SKILL.md"))
1936 });
1937
1938 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1939
1940 assert!(error.to_string().contains("cannot write"));
1941 assert!(!skill_path.exists());
1942 assert!(!registry_path.exists());
1943 assert!(unrelated_file.is_file());
1944 assert!(fs::read_dir(skills_root).unwrap().all(|entry| {
1945 !entry
1946 .unwrap()
1947 .file_name()
1948 .to_string_lossy()
1949 .starts_with(".agent-context-staging-")
1950 }));
1951 }
1952
1953 #[test]
1954 fn reports_directory_tree_creation_errors() {
1955 let directory = tempdir().unwrap();
1956 let destination = directory.path().join("created/nested");
1957 let failure_path = destination.clone();
1958 let _failure =
1959 filesystem::fail_once(filesystem::Operation::CreateDirectoryTree, move |path| {
1960 path == failure_path
1961 });
1962
1963 let error = ensure_directory(&destination).unwrap_err();
1964
1965 assert!(error.to_string().contains("cannot create"));
1966 assert!(!destination.exists());
1967 }
1968
1969 #[test]
1970 fn reports_skill_registry_read_errors() {
1971 let directory = tempdir().unwrap();
1972 let registry_path = directory.path().join("registry.json");
1973 fs::write(®istry_path, r#"{"version":1,"skills":{}}"#).unwrap();
1974 let failure_path = registry_path.clone();
1975 let _failure = filesystem::fail_once(filesystem::Operation::Read, move |path| {
1976 path == failure_path
1977 });
1978
1979 let error = load_registry(®istry_path).unwrap_err();
1980
1981 assert!(error.to_string().contains("cannot read"));
1982 }
1983
1984 #[test]
1985 fn reports_destination_inspection_errors_before_changing_installed_skills() {
1986 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
1987 transaction_fixture();
1988 let failure_path = previous_skill.clone();
1989 let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| {
1990 path == failure_path
1991 });
1992
1993 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
1994
1995 assert!(error.to_string().contains("cannot inspect"));
1996 assert_transaction_restored(
1997 &previous_skill,
1998 &stale_skill,
1999 ®istry_path,
2000 &previous_registry,
2001 );
2002 }
2003
2004 #[test]
2005 fn reports_stale_skill_inspection_errors_before_changing_installed_skills() {
2006 let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) =
2007 transaction_fixture();
2008 let failure_path = stale_skill.clone();
2009 let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| {
2010 path == failure_path
2011 });
2012
2013 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
2014
2015 assert!(error.to_string().contains("cannot inspect"));
2016 assert_transaction_restored(
2017 &previous_skill,
2018 &stale_skill,
2019 ®istry_path,
2020 &previous_registry,
2021 );
2022 }
2023
2024 #[test]
2025 fn reports_staging_cleanup_errors_after_committing_the_install() {
2026 let (_directory, installer, previous_skill, stale_skill, registry_path, _) =
2027 transaction_fixture();
2028 let _failure = filesystem::fail_once(filesystem::Operation::RemoveDirectoryTree, |path| {
2029 path.file_name()
2030 .and_then(std::ffi::OsStr::to_str)
2031 .is_some_and(|name| name.starts_with(".agent-context-staging-"))
2032 });
2033
2034 let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err();
2035
2036 assert!(error.to_string().contains("cannot remove"));
2037 assert!(
2038 fs::read_to_string(previous_skill.join("SKILL.md"))
2039 .unwrap()
2040 .contains("# Updated example")
2041 );
2042 assert!(!stale_skill.exists());
2043 let registry = load_registry(®istry_path).unwrap();
2044 assert_eq!(registry.skills["provider-example"].version, "1.0.0");
2045 assert!(!registry.skills.contains_key("provider-stale"));
2046 }
2047
2048 #[cfg(unix)]
2049 #[test]
2050 fn handles_exclude_errors_before_installing_skills() {
2051 use std::os::unix::fs::PermissionsExt;
2052
2053 let directory = tempdir().unwrap();
2054 let root = directory.path();
2055 assert!(
2056 std::process::Command::new("git")
2057 .args(["init", "--quiet"])
2058 .current_dir(root)
2059 .status()
2060 .unwrap()
2061 .success()
2062 );
2063 let exclude = root.join(".git/info/exclude");
2064 fs::remove_file(&exclude).unwrap();
2065 fs::create_dir(&exclude).unwrap();
2066 let provider = package(root, "provider", "1.0.0");
2067 write_skill(&provider, "one.md", &skill_document("One.", "# One\n"));
2068 let installer = make_installer(root, vec![provider]);
2069 let error = install_skills(&installer, None, None)
2070 .unwrap_err()
2071 .to_string();
2072 assert!(error.contains("cannot read"), "{error}");
2073 assert!(!root.join(".agents/skills/provider-one").exists());
2074
2075 let directory = tempdir().unwrap();
2076 let root = directory.path();
2077 assert!(
2078 std::process::Command::new("git")
2079 .args(["init", "--quiet"])
2080 .current_dir(root)
2081 .status()
2082 .unwrap()
2083 .success()
2084 );
2085 let exclude = root.join(".git/info/exclude");
2086 fs::set_permissions(&exclude, fs::Permissions::from_mode(0o444)).unwrap();
2087 let provider = package(root, "provider", "1.0.0");
2088 write_skill(&provider, "one.md", &skill_document("One.", "# One\n"));
2089 let installer = make_installer(root, vec![provider]);
2090 let result = install_skills(&installer, None, None);
2091 fs::set_permissions(&exclude, fs::Permissions::from_mode(0o644)).unwrap();
2092 let error = result.unwrap_err().to_string();
2093 assert!(error.contains("cannot update"), "{error}");
2094 assert!(!root.join(".agents/skills/provider-one").exists());
2095 assert!(!root.join(".agents/skills").join(REGISTRY_FILE).exists());
2096 }
2097}