Skip to main content

bake_agent_context/
skill.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4#[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/// A skill declared by a Markdown file in a dependency's `context/` directory.
162#[derive(Clone, Debug)]
163pub struct Skill {
164    /// Globally unique installed name, prefixed with the provider crate name.
165    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    /// The package selector accepted by `--package`.
175    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
215/// Find context documents marked with `type: skill` in YAML front matter.
216pub 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    // MDX parsing is disabled here, so Markdown syntax itself cannot fail.
273    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    // parse_skill_document only calls this after context_frontmatter has found
350    // and parsed the leading YAML block.
351    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
358/// Install skills from all providers, one provider, or one named skill.
359///
360/// If both filters are omitted, all discovered skills are installed. Existing
361/// project-owned skill directories are never replaced; installed dependency
362/// skills are tracked in a registry under `.agents/skills/`.
363pub fn install_skills(
364    installer: &Installer,
365    package_selector: Option<&str>,
366    skill_name: Option<&str>,
367) -> Result<Vec<String>> {
368    // Resolve a package selector only once. Resolving it again after listing
369    // skills introduced a second, unreachable error path and a small TOCTOU
370    // window if package state ever becomes mutable.
371    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(&registry_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(&registry)
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(&registry_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, &registry_path) {
591        if had_registry {
592            let _ = filesystem::rename(backups.join("registry.json"), &registry_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(&registry_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(&copy_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(&copy_source, &copy_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(&registry_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(&registry_path).unwrap();
1479        assert!(
1480            load_registry(&registry_path)
1481                .err()
1482                .unwrap()
1483                .to_string()
1484                .contains("not a regular file")
1485        );
1486        fs::remove_dir(&registry_path).unwrap();
1487        fs::write(&registry_path, "not json").unwrap();
1488        assert!(
1489            load_registry(&registry_path)
1490                .err()
1491                .unwrap()
1492                .to_string()
1493                .contains("invalid skill registry")
1494        );
1495        fs::write(&registry_path, r#"{"version": 2, "skills": {}}"#).unwrap();
1496        assert!(
1497            load_registry(&registry_path)
1498                .err()
1499                .unwrap()
1500                .to_string()
1501                .contains("unsupported skill registry version")
1502        );
1503        fs::write(&registry_path, r#"{"version": 1, "skills": {"Bad_Name": {"package": "provider", "version": "1.0.0"}}}"#).unwrap();
1504        assert!(
1505            load_registry(&registry_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(&registry_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(&registry).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(&registry).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(&registry).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(&registry).unwrap();
1822        fs::write(&registry_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            &registry_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            &registry_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            &registry_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            &registry_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            &registry_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            &registry_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            &registry_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(&registry_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(&registry_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            &registry_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            &registry_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(&registry_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}