Skip to main content

bake_agent_context/
skill.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use super::document::Document;
5use super::installer::{Specification, remove_existing};
6use super::ownership::installed_skill_owners;
7#[cfg(test)]
8use super::test_filesystem as filesystem;
9use bake::{Error, Result};
10use serde_yaml_ng::Value;
11use std::collections::HashSet;
12#[cfg(not(test))]
13use std::fs as filesystem;
14use std::path::{Path, PathBuf};
15
16/// A skill declared by a dependency context document.
17#[derive(Clone, Debug)]
18pub struct SkillDefinition {
19    /// Authored or derived name used for the installed directory.
20    pub name: String,
21    pub description: String,
22    pub specification: Specification,
23    /// The source path relative to the provider's context directory.
24    pub path: PathBuf,
25    document: Document,
26    assets: Option<PathBuf>,
27    installation_content: String,
28}
29
30impl SkillDefinition {
31    /// The package selector accepted by `--package`.
32    pub fn package_selector(&self) -> &str {
33        self.specification.selector()
34    }
35
36    /// The authored Markdown source.
37    pub fn content(&self) -> &str {
38        self.document.content()
39    }
40
41    /// The source file path.
42    pub fn source_path(&self) -> &Path {
43        &self.document.path
44    }
45
46    /// Generated instructions, including resolved provenance.
47    pub fn installation_content(&self) -> &str {
48        &self.installation_content
49    }
50
51    /// Apply skill-specific rules to a parsed document.
52    pub(crate) fn from_document(
53        specification: &Specification,
54        document: &Document,
55    ) -> Result<Self> {
56        let source = &document.path;
57        if source.parent() != Some(specification.context_path.as_path()) {
58            return Err(Error::new(format!(
59                "skill document {} must be directly inside context/",
60                source.display()
61            )));
62        }
63
64        // Preserve authored names and derive missing names from the provider and file:
65        let source_name = source_name(source)?;
66        let name = match document.frontmatter.get("name") {
67            Some(Value::String(name)) => name.clone(),
68            None => format!(
69                "{}-{source_name}",
70                specification.name.to_ascii_lowercase().replace('_', "-")
71            ),
72            _ => {
73                return Err(Error::new(format!(
74                    "invalid skill name in {}",
75                    source.display()
76                )));
77            }
78        };
79        validate_skill_name(&name)?;
80        let description = document
81            .frontmatter
82            .get("description")
83            .and_then(Value::as_str)
84            .filter(|value| !value.trim().is_empty() && value.trim().chars().count() <= 1024)
85            .ok_or_else(|| {
86                Error::new(format!(
87                    "skill {name:?} in {} requires a description of 1–1024 characters",
88                    source.display()
89                ))
90            })?
91            .to_owned();
92
93        // Add resolved provenance while retaining the provider's remaining metadata:
94        let assets = skill_assets_path(&specification.context_path, &source_name)?;
95        let mut frontmatter = document.frontmatter.clone();
96        frontmatter.insert("name".into(), name.clone().into());
97        let mut metadata = document.metadata().cloned().unwrap_or_default();
98        metadata.insert("ecosystem".into(), "cargo".into());
99        metadata.insert("package".into(), specification.name.clone().into());
100        metadata.insert("version".into(), specification.version.clone().into());
101        frontmatter.insert("metadata".into(), Value::Mapping(metadata));
102        let installation_content = document.render(frontmatter);
103
104        Ok(Self {
105            name,
106            description,
107            specification: specification.clone(),
108            path: source.file_name().unwrap().into(),
109            document: document.clone(),
110            assets,
111            installation_content,
112        })
113    }
114
115    /// Write instructions before resources so ownership remains identifiable during refresh.
116    pub(crate) fn install(&self, destination: &Path) -> Result<()> {
117        filesystem::create_dir_all(destination).map_err(|error| {
118            Error::new(format!("cannot create {}: {error}", destination.display()))
119        })?;
120        let path = destination.join("SKILL.md");
121        if let Err(error) = filesystem::write(&path, &self.installation_content) {
122            // A failed instruction write has not established usable ownership:
123            let _ = filesystem::remove_dir_all(destination);
124            return Err(Error::new(format!(
125                "cannot write {}: {error}",
126                path.display()
127            )));
128        }
129        if let Some(assets) = &self.assets {
130            copy_skill_assets(assets, destination, true)?;
131        }
132        Ok(())
133    }
134}
135
136fn skill_assets_path(context_path: &Path, source_name: &str) -> Result<Option<PathBuf>> {
137    let assets = context_path.join(source_name);
138    match filesystem::symlink_metadata(&assets) {
139        Ok(metadata) if metadata.file_type().is_dir() => Ok(Some(assets)),
140        Ok(_) => Err(Error::new(format!(
141            "skill assets path {} is not a directory",
142            assets.display()
143        ))),
144        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
145        Err(error) => Err(Error::new(format!(
146            "cannot inspect skill assets {}: {error}",
147            assets.display()
148        ))),
149    }
150}
151
152/// Refresh loaded skills and reconcile stale destinations within the selected scope.
153pub(crate) fn install_definitions(
154    root: &Path,
155    skills: &[SkillDefinition],
156    package: Option<&str>,
157    reconcile: bool,
158) -> Result<Vec<String>> {
159    // Check selected names and destination ownership before refreshing directories:
160    let mut names = HashSet::new();
161    for skill in skills {
162        if !names.insert(skill.name.clone()) {
163            return Err(Error::new(format!(
164                "multiple selected crates provide skill {:?}; select one with --package",
165                skill.name
166            )));
167        }
168    }
169    let skills_root = root.join(".agents/skills");
170    let owners = installed_skill_owners(&skills_root)?;
171    for skill in skills {
172        let destination = skills_root.join(&skill.name);
173        if path_exists(&destination)?
174            && owners.get(&skill.name).is_none_or(|owner| {
175                owner.ecosystem != "cargo" || owner.package != skill.specification.name
176            })
177        {
178            return Err(Error::new(format!(
179                "skill destination {} belongs to a project or another provider",
180                destination.display()
181            )));
182        }
183    }
184    let stale: Vec<_> = owners
185        .iter()
186        .filter(|(name, owner)| {
187            reconcile
188                && owner.ecosystem == "cargo"
189                && package.is_none_or(|package| owner.package == package)
190                && !names.contains(*name)
191        })
192        .map(|(name, _)| name.clone())
193        .collect();
194
195    // Write each selected skill from the instructions already loaded:
196    filesystem::create_dir_all(&skills_root)
197        .map_err(|error| Error::new(format!("cannot create {}: {error}", skills_root.display())))?;
198    for skill in skills {
199        let destination = skills_root.join(&skill.name);
200        remove_existing(&destination)?;
201        skill.install(&destination)?;
202    }
203
204    // Remove stale skills and update this project's local Git exclusions:
205    for name in &stale {
206        remove_existing(&skills_root.join(name))?;
207    }
208    let remaining = owners
209        .keys()
210        .filter(|name| !stale.contains(name))
211        .cloned()
212        .chain(names);
213    if let Some(update) = super::exclude::prepare(root, remaining)? {
214        update.apply()?;
215    }
216    Ok(skills.iter().map(|skill| skill.name.clone()).collect())
217}
218
219pub(crate) fn validate_skill_name(name: &str) -> Result<()> {
220    let valid = !name.is_empty()
221        && name.len() <= 64
222        && !name.starts_with('-')
223        && !name.ends_with('-')
224        && !name.contains("--")
225        && name
226            .bytes()
227            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
228    if valid {
229        Ok(())
230    } else {
231        Err(Error::new(format!(
232            "invalid skill name {name:?}; use 1–64 lowercase ASCII letters, digits, or single hyphens"
233        )))
234    }
235}
236
237fn source_name(source: &Path) -> Result<String> {
238    let Some(stem) = source.file_stem().and_then(|stem| stem.to_str()) else {
239        return Err(Error::new(format!(
240            "invalid skill filename: {}",
241            source.display()
242        )));
243    };
244    Ok(stem.to_owned())
245}
246
247fn copy_skill_assets(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
248    let entries = filesystem::read_dir(source)
249        .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?;
250    let mut entries = entries.collect::<std::io::Result<Vec<_>>>()?;
251    entries.sort_by_key(|entry| entry.file_name());
252
253    for entry in entries {
254        let source_path = entry.path();
255        let destination_path = destination.join(entry.file_name());
256        copy_skill_asset(&source_path, &destination_path, top_level)?;
257    }
258
259    Ok(())
260}
261
262fn copy_skill_asset(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
263    let metadata = inspect_skill_asset(source)?;
264    let file_type = metadata.file_type();
265
266    let name = source.file_name().unwrap_or_default().to_string_lossy();
267    if top_level && name.eq_ignore_ascii_case("SKILL.md") {
268        return Err(Error::new(format!(
269            "{} is reserved for generated skill files",
270            source.display()
271        )));
272    }
273
274    if file_type.is_symlink() {
275        return Err(Error::new(format!(
276            "skill assets cannot contain symbolic links: {}",
277            source.display()
278        )));
279    } else if file_type.is_dir() {
280        filesystem::create_dir(destination).map_err(|error| {
281            Error::new(format!("cannot create {}: {error}", destination.display()))
282        })?;
283        copy_skill_assets(source, destination, false)?;
284    } else if file_type.is_file() {
285        filesystem::copy(source, destination)
286            .map(|_| ())
287            .map_err(|error| {
288                Error::new(format!(
289                    "cannot copy {} to {}: {error}",
290                    source.display(),
291                    destination.display()
292                ))
293            })?;
294    } else {
295        return Err(Error::new(format!(
296            "unsupported skill asset: {}",
297            source.display()
298        )));
299    }
300    Ok(())
301}
302
303fn inspect_skill_asset(path: &Path) -> Result<filesystem::Metadata> {
304    filesystem::symlink_metadata(path)
305        .map_err(|error| Error::new(format!("cannot inspect {}: {error}", path.display())))
306}
307
308fn path_exists(path: &Path) -> Result<bool> {
309    match filesystem::symlink_metadata(path) {
310        Ok(_) => Ok(true),
311        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
312        Err(error) => Err(Error::new(format!(
313            "cannot inspect {}: {error}",
314            path.display()
315        ))),
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::super::installer::{Definition, Installer};
322    use super::*;
323    use std::fs;
324    use tempfile::tempdir;
325
326    fn list_skills(installer: &Installer, selector: Option<&str>) -> Result<Vec<SkillDefinition>> {
327        let providers = if let Some(selector) = selector {
328            installer.find_provider(selector)?.into_iter().collect()
329        } else {
330            installer.providers()?
331        };
332
333        let mut skills = Vec::new();
334        for provider in providers {
335            for definition in provider.definitions {
336                if let Definition::Skill(skill) = definition {
337                    skills.push(skill);
338                }
339            }
340        }
341        skills.sort_by(|left, right| {
342            left.specification
343                .name
344                .cmp(&right.specification.name)
345                .then_with(|| left.specification.version.cmp(&right.specification.version))
346                .then_with(|| left.name.cmp(&right.name))
347        });
348        Ok(skills)
349    }
350
351    fn install_skills(installer: &Installer, selector: Option<&str>) -> Result<Vec<String>> {
352        let skills = list_skills(installer, selector)?;
353        let package = if selector.is_some() {
354            skills
355                .first()
356                .map(|skill| skill.specification.name.as_str())
357        } else {
358            None
359        };
360        install_definitions(installer.root(), &skills, package, true)
361    }
362
363    fn specification(root: &Path, name: &str, version: &str) -> Specification {
364        let path = root.join(format!("{name}-{version}/context"));
365        fs::create_dir_all(&path).unwrap();
366        Specification::for_test(name, version, path)
367    }
368
369    fn write_skill(package: &Specification, name: &str, fields: &str, body: &str) {
370        fs::write(
371            package.context_path.join(format!("{name}.md")),
372            format!("---\nmetadata:\n  type: skill\n{fields}---\n{body}"),
373        )
374        .unwrap();
375    }
376
377    fn seed_owner(path: &Path, ecosystem: &str, package: &str) {
378        fs::create_dir_all(path).unwrap();
379        fs::write(path.join("SKILL.md"), format!("---\nmetadata:\n  type: skill\n  ecosystem: {ecosystem}\n  package: {package}\n  version: '1.0'\n---\nInstructions")).unwrap();
380    }
381
382    #[test]
383    fn preserves_authored_names_descriptions_and_metadata() {
384        let directory = tempdir().unwrap();
385        let package = specification(directory.path(), "provider", "2.0.0");
386        fs::write(
387            package.context_path.join("workflow.md"),
388            include_str!("../tests/fixtures/SKILL.md"),
389        )
390        .unwrap();
391        let installer = Installer::for_test(directory.path(), vec![package.clone()]);
392        let skills = list_skills(&installer, None).unwrap();
393        assert_eq!(skills[0].name, "provider-workflow");
394        assert_eq!(
395            skills[0].source_path(),
396            package.context_path.join("workflow.md")
397        );
398        assert!(skills[0].content().contains("author: Provider"));
399        assert!(
400            skills[0]
401                .installation_content()
402                .contains("ecosystem: cargo")
403        );
404        assert!(skills[0].installation_content().contains("version: 2.0.0"));
405        write_skill(
406            &package,
407            "workflow",
408            "name: custom-workflow\ndescription: '  Run workflow.  '\n",
409            "\nBody",
410        );
411        let skills = list_skills(&installer, None).unwrap();
412        assert_eq!(skills[0].name, "custom-workflow");
413        assert_eq!(skills[0].description, "  Run workflow.  ");
414        install_skills(&installer, None).unwrap();
415        assert!(
416            directory
417                .path()
418                .join(".agents/skills/custom-workflow/SKILL.md")
419                .is_file()
420        );
421    }
422
423    #[test]
424    fn sorts_providers_and_detects_ambiguous_installed_names() {
425        let directory = tempdir().unwrap();
426        let root = directory.path();
427        let first = specification(root, "a_provider", "1.0.0");
428        let second = specification(root, "a_provider", "2.0.0");
429        let last = specification(root, "z-provider", "1.0.0");
430        fs::write(first.context_path.join("guide.md"), "# Guide\n").unwrap();
431        for package in [&last, &second, &first] {
432            write_skill(package, "zeta", "description: Zeta.\n", "Z");
433            write_skill(package, "alpha", "description: Alpha.\n", "A");
434        }
435        let installer = Installer::for_test(root, vec![last, second, first]);
436        assert!(
437            list_skills(&installer, Some("missing-provider"))
438                .unwrap()
439                .is_empty()
440        );
441        let skills = list_skills(&installer, None).unwrap();
442        assert_eq!(skills[0].name, "a-provider-alpha");
443        assert_eq!(skills[0].specification.version, "1.0.0");
444        assert_eq!(skills.last().unwrap().name, "z-provider-zeta");
445        assert!(
446            install_skills(&installer, None)
447                .unwrap_err()
448                .to_string()
449                .contains("multiple selected")
450        );
451        assert!(install_skills(&installer, Some("a_provider")).is_err());
452        assert!(list_skills(&installer, Some("a_provider")).is_err());
453    }
454
455    #[test]
456    fn validates_names_and_description_boundaries() {
457        let directory = tempdir().unwrap();
458        let package = specification(directory.path(), "provider", "1.0.0");
459        let installer = Installer::for_test(directory.path(), vec![package.clone()]);
460        for fields in [
461            "name: 123\ndescription: Valid.\n".to_owned(),
462            "name: null\ndescription: Valid.\n".to_owned(),
463            "name: Bad_Name\ndescription: Valid.\n".to_owned(),
464            "description: []\n".to_owned(),
465            "description: '   '\n".to_owned(),
466            format!("description: {}\n", "é".repeat(1025)),
467        ] {
468            write_skill(&package, "workflow", &fields, "Body");
469            assert!(list_skills(&installer, None).is_err(), "{fields}");
470        }
471        write_skill(
472            &package,
473            "workflow",
474            &format!(
475                "name: {}\ndescription: '  {}  '\n",
476                "a".repeat(64),
477                "é".repeat(1024)
478            ),
479            "Body",
480        );
481        assert_eq!(list_skills(&installer, None).unwrap()[0].name.len(), 64);
482        for name in ["", "-a", "a-", "a--b", "UPPER", &"a".repeat(65)] {
483            assert!(validate_skill_name(name).is_err());
484        }
485        assert!(source_name(Path::new("/")).is_err());
486    }
487
488    #[test]
489    fn reconciles_only_the_selected_ecosystem_and_package() {
490        let directory = tempdir().unwrap();
491        let root = directory.path();
492        let package = specification(root, "provider", "1.0.0");
493        write_skill(&package, "workflow", "description: Workflow.\n", "Body");
494        let installer = Installer::for_test(root, vec![package.clone()]);
495        let skills = root.join(".agents/skills");
496        seed_owner(&skills.join("stale-provider"), "cargo", "provider");
497        seed_owner(&skills.join("other-workflow"), "cargo", "other");
498        seed_owner(&skills.join("ruby-workflow"), "gem", "provider");
499        install_skills(&installer, Some("provider")).unwrap();
500        assert!(!skills.join("stale-provider").exists());
501        assert!(skills.join("other-workflow").exists());
502        assert!(skills.join("ruby-workflow").exists());
503        fs::remove_file(package.context_path.join("workflow.md")).unwrap();
504        install_skills(&installer, Some("provider")).unwrap();
505        assert!(!skills.join("provider-workflow").exists());
506        install_skills(&Installer::for_test(root, vec![]), None).unwrap();
507        assert!(!skills.join("other-workflow").exists());
508        assert!(skills.join("ruby-workflow").exists());
509    }
510
511    #[test]
512    fn rejects_other_providers_and_accepts_the_same_owner_at_a_new_version() {
513        let directory = tempdir().unwrap();
514        let package = specification(directory.path(), "provider", "2.0.0");
515        write_skill(&package, "workflow", "description: Workflow.\n", "Body");
516        let installer = Installer::for_test(directory.path(), vec![package]);
517        let destination = directory.path().join(".agents/skills/provider-workflow");
518        for (ecosystem, package) in [("gem", "provider"), ("cargo", "other")] {
519            seed_owner(&destination, ecosystem, package);
520            assert!(install_skills(&installer, None).is_err());
521        }
522        seed_owner(&destination, "cargo", "provider");
523        install_skills(&installer, None).unwrap();
524        assert!(
525            fs::read_to_string(destination.join("SKILL.md"))
526                .unwrap()
527                .contains("version: 2.0.0")
528        );
529    }
530
531    #[test]
532    fn resource_failure_leaves_identifiable_progress_and_retry_refreshes_it() {
533        let directory = tempdir().unwrap();
534        let package = specification(directory.path(), "provider", "1.0.0");
535        write_skill(&package, "workflow", "description: Workflow.\n", "Body");
536        let resources = package.context_path.join("workflow");
537        fs::create_dir_all(resources.join("references")).unwrap();
538        fs::write(resources.join("references/data.md"), "---\n[invalid YAML\n").unwrap();
539        fs::write(resources.join("notes.txt"), "opaque asset").unwrap();
540        let installer = Installer::for_test(directory.path(), vec![package.clone()]);
541        let failure_path = resources.join("notes.txt");
542        let _failure = filesystem::fail_once(filesystem::Operation::Copy, move |path| {
543            path == failure_path
544        });
545        assert!(install_skills(&installer, None).is_err());
546        let skills = directory.path().join(".agents/skills");
547        assert_eq!(
548            installed_skill_owners(&skills).unwrap()["provider-workflow"].package,
549            "provider"
550        );
551        install_skills(&installer, None).unwrap();
552        assert_eq!(
553            fs::read_to_string(skills.join("provider-workflow/notes.txt")).unwrap(),
554            "opaque asset"
555        );
556        fs::remove_file(resources.join("notes.txt")).unwrap();
557        install_skills(&installer, None).unwrap();
558        assert!(!skills.join("provider-workflow/notes.txt").exists());
559    }
560
561    #[test]
562    fn reserves_only_the_top_level_instruction_filename() {
563        let directory = tempdir().unwrap();
564        let source = directory.path().join("source");
565        let destination = directory.path().join("destination");
566        fs::create_dir(&source).unwrap();
567        fs::create_dir(&destination).unwrap();
568        for name in ["SKILL.md", "skill.MD"] {
569            fs::write(source.join(name), "conflict").unwrap();
570            assert!(copy_skill_assets(&source, &destination, true).is_err());
571            fs::remove_file(source.join(name)).unwrap();
572            fs::create_dir(source.join(name)).unwrap();
573            assert!(copy_skill_assets(&source, &destination, true).is_err());
574            fs::remove_dir(source.join(name)).unwrap();
575        }
576        fs::create_dir(source.join("references")).unwrap();
577        fs::write(source.join("references/SKILL.md"), "opaque").unwrap();
578        copy_skill_assets(&source, &destination, true).unwrap();
579        assert_eq!(
580            fs::read_to_string(destination.join("references/SKILL.md")).unwrap(),
581            "opaque"
582        );
583    }
584    #[test]
585    fn instruction_write_failure_cleans_the_directory_and_retry_succeeds() {
586        let directory = tempdir().unwrap();
587        let root = directory.path();
588        let package = specification(root, "provider", "1.0.0");
589        write_skill(&package, "workflow", "description: Workflow.\n", "Body");
590        let installer = Installer::for_test(root, vec![package]);
591        let destination = root.join(".agents/skills/provider-workflow");
592        let instructions = destination.join("SKILL.md");
593        let _failure = filesystem::fail_once(filesystem::Operation::Write, move |path| {
594            path == instructions
595        });
596        assert!(
597            install_skills(&installer, None)
598                .unwrap_err()
599                .to_string()
600                .contains("cannot write")
601        );
602        assert!(!destination.exists());
603        install_skills(&installer, None).unwrap();
604        assert_eq!(
605            installed_skill_owners(&root.join(".agents/skills")).unwrap()["provider-workflow"]
606                .package,
607            "provider"
608        );
609    }
610
611    #[test]
612    fn reports_discovery_and_refresh_io_failures() {
613        use filesystem::Operation;
614        let directory = tempdir().unwrap();
615        let root = directory.path();
616        let package = specification(root, "provider", "1.0.0");
617        write_skill(&package, "workflow", "description: Workflow.\n", "Body");
618        let installer = Installer::for_test(root, vec![package.clone()]);
619        let skills = root.join(".agents/skills");
620        let destination = skills.join("provider-workflow");
621        for (operation, path) in [
622            (Operation::ReadDirectory, package.context_path.clone()),
623            (Operation::Inspect, package.context_path.join("workflow")),
624            (Operation::Inspect, destination.clone()),
625            (Operation::CreateDirectoryTree, destination.clone()),
626        ] {
627            let _failure = filesystem::fail_once(operation, move |candidate| candidate == path);
628            assert!(
629                install_skills(&installer, None)
630                    .unwrap_err()
631                    .to_string()
632                    .contains("injected filesystem failure")
633            );
634        }
635        install_skills(&installer, None).unwrap();
636        let failure_path = destination.clone();
637        let _failure = filesystem::fail_once(Operation::RemoveDirectoryTree, move |path| {
638            path == failure_path
639        });
640        assert!(
641            install_skills(&installer, None)
642                .unwrap_err()
643                .to_string()
644                .contains("cannot remove")
645        );
646        fs::remove_file(package.context_path.join("workflow.md")).unwrap();
647        let _failure = filesystem::fail_once(Operation::RemoveDirectoryTree, move |path| {
648            path == destination
649        });
650        assert!(
651            install_skills(&installer, None)
652                .unwrap_err()
653                .to_string()
654                .contains("cannot remove")
655        );
656    }
657
658    #[test]
659    fn reports_resource_failures_and_keeps_the_specification_identity() {
660        use filesystem::Operation;
661        let directory = tempdir().unwrap();
662        let root = directory.path();
663        let package = specification(root, "provider", "1.0.0");
664        write_skill(&package, "workflow", "description: Workflow.\n", "Body");
665        let resources = package.context_path.join("workflow");
666        fs::create_dir_all(resources.join("references")).unwrap();
667        fs::write(resources.join("references/guide.md"), "# Guide").unwrap();
668        let installer = Installer::for_test(root, vec![package]);
669        let destination = root.join(".agents/skills/provider-workflow");
670        for (operation, path) in [
671            (Operation::ReadDirectory, resources.clone()),
672            (Operation::ReadDirectoryEntry, resources.clone()),
673            (Operation::Inspect, resources.join("references")),
674            (Operation::CreateDirectory, destination.join("references")),
675            (Operation::ReadDirectory, resources.join("references")),
676        ] {
677            let _failure = filesystem::fail_once(operation, move |candidate| candidate == path);
678            assert!(
679                install_skills(&installer, None)
680                    .unwrap_err()
681                    .to_string()
682                    .contains("injected filesystem failure")
683            );
684            assert_eq!(
685                installed_skill_owners(&root.join(".agents/skills")).unwrap()["provider-workflow"]
686                    .package,
687                "provider"
688            );
689        }
690    }
691
692    #[test]
693    fn reports_git_exclusion_errors_after_installing_identifiable_skills() {
694        let directory = tempdir().unwrap();
695        let root = directory.path();
696        assert!(
697            std::process::Command::new("git")
698                .args(["init", "--quiet"])
699                .arg(root)
700                .status()
701                .unwrap()
702                .success()
703        );
704        let package = specification(root, "provider", "1.0.0");
705        write_skill(&package, "workflow", "description: Workflow.\n", "Body");
706        let installer = Installer::for_test(root, vec![package]);
707        let exclude = root.join(".git/info/exclude");
708        fs::write(&exclude, "# BEGIN bake-agent-context\n").unwrap();
709        assert!(install_skills(&installer, None).is_err());
710        fs::write(&exclude, "# Project exclusions\n").unwrap();
711        let _failure =
712            filesystem::fail_once(filesystem::Operation::Write, move |path| path == exclude);
713        assert!(
714            install_skills(&installer, None)
715                .unwrap_err()
716                .to_string()
717                .contains("injected filesystem failure")
718        );
719        assert_eq!(
720            installed_skill_owners(&root.join(".agents/skills")).unwrap()["provider-workflow"]
721                .package,
722            "provider"
723        );
724    }
725
726    #[cfg(unix)]
727    #[test]
728    fn rejects_non_utf8_skill_filenames() {
729        use std::os::unix::ffi::OsStringExt;
730        let directory = tempdir().unwrap();
731        let package = specification(directory.path(), "provider", "1.0.0");
732        let name = std::ffi::OsString::from_vec(b"invalid-\xff.md".to_vec());
733        let document = Document::parse(
734            "---\nmetadata: {type: skill}\ndescription: Workflow.\n---\n".to_owned(),
735            &package.context_path.join(name),
736        )
737        .unwrap();
738        assert!(
739            SkillDefinition::from_document(&package, &document)
740                .unwrap_err()
741                .to_string()
742                .contains("invalid skill filename")
743        );
744    }
745}