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