Skip to main content

bake_agent_context/
skill.rs

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